\n \n %s\n \n%s
",
+ s.SummaryID,
+ string(s.Kind),
+ s.Depth,
+ s.DescendantCount,
+ attrs,
+ escapeXML(s.Content),
+ parentsSection,
+ )
+}
diff --git a/pkg/seahorse/short_assembler_test.go b/pkg/seahorse/short_assembler_test.go
new file mode 100644
index 000000000..88a05e64c
--- /dev/null
+++ b/pkg/seahorse/short_assembler_test.go
@@ -0,0 +1,536 @@
+package seahorse
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+)
+
+// --- Assembler Tests ---
+
+// helper: create a store with messages and summaries for assembly tests
+func setupAssemblerStore(t *testing.T) (*Store, int64) {
+ t.Helper()
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, err := s.GetOrCreateConversation(ctx, "test:assemble")
+ if err != nil {
+ t.Fatalf("create conversation: %v", err)
+ }
+
+ return s, conv.ConversationID
+}
+
+func TestAssemblerAssembleEmpty(t *testing.T) {
+ s, convID := setupAssemblerStore(t)
+ ctx := context.Background()
+
+ a := &Assembler{store: s, config: Config{}}
+ result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 1000})
+ if err != nil {
+ t.Fatalf("Assemble: %v", err)
+ }
+ if len(result.Messages) != 0 {
+ t.Errorf("Messages = %d, want 0", len(result.Messages))
+ }
+ if result.Summary != "" {
+ t.Errorf("Summary = %q, want empty", result.Summary)
+ }
+}
+
+func TestAssemblerAssembleMessagesOnly(t *testing.T) {
+ s, convID := setupAssemblerStore(t)
+ ctx := context.Background()
+
+ // Create messages
+ msg1, _ := s.AddMessage(ctx, convID, "user", "hello", 5)
+ msg2, _ := s.AddMessage(ctx, convID, "assistant", "world", 5)
+
+ // Create context items
+ s.UpsertContextItems(ctx, convID, []ContextItem{
+ {Ordinal: 100, ItemType: "message", MessageID: msg1.ID, TokenCount: 5},
+ {Ordinal: 200, ItemType: "message", MessageID: msg2.ID, TokenCount: 5},
+ })
+
+ a := &Assembler{store: s, config: Config{}}
+ result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 100})
+ if err != nil {
+ t.Fatalf("Assemble: %v", err)
+ }
+
+ if len(result.Messages) != 2 {
+ t.Fatalf("Messages = %d, want 2", len(result.Messages))
+ }
+ if result.Messages[0].Content != "hello" {
+ t.Errorf("Messages[0].Content = %q, want 'hello'", result.Messages[0].Content)
+ }
+ if result.Messages[1].Content != "world" {
+ t.Errorf("Messages[1].Content = %q, want 'world'", result.Messages[1].Content)
+ }
+ // No summaries, so Summary should be empty
+ if result.Summary != "" {
+ t.Errorf("Summary = %q, want empty", result.Summary)
+ }
+}
+
+func TestAssemblerAssembleWithSummary(t *testing.T) {
+ s, convID := setupAssemblerStore(t)
+ ctx := context.Background()
+
+ // Create a summary
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "summary of early messages",
+ TokenCount: 50,
+ })
+
+ // Create recent messages
+ msg1, _ := s.AddMessage(ctx, convID, "user", "recent", 5)
+ msg2, _ := s.AddMessage(ctx, convID, "assistant", "reply", 5)
+
+ // Context: summary + recent messages
+ s.UpsertContextItems(ctx, convID, []ContextItem{
+ {Ordinal: 100, ItemType: "summary", SummaryID: summary.SummaryID, TokenCount: 50},
+ {Ordinal: 200, ItemType: "message", MessageID: msg1.ID, TokenCount: 5},
+ {Ordinal: 300, ItemType: "message", MessageID: msg2.ID, TokenCount: 5},
+ })
+
+ a := &Assembler{store: s, config: Config{}}
+ result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 1000})
+ if err != nil {
+ t.Fatalf("Assemble: %v", err)
+ }
+
+ // Messages = 2 raw messages (summaries are in Summary field, not Messages)
+ if len(result.Messages) != 2 {
+ t.Errorf("Messages = %d, want 2 (raw messages only)", len(result.Messages))
+ }
+ // Summary should contain XML with summary content
+ if result.Summary == "" {
+ t.Error("Summary should not be empty when summary exists")
+ }
+ if !strings.Contains(result.Summary, summary.Content) {
+ t.Errorf("Summary should contain summary content %q", summary.Content)
+ }
+ if !strings.Contains(result.Summary, "`,
+ TokenCount: 20,
+ })
+
+ s.UpsertContextItems(ctx, convID, []ContextItem{
+ {Ordinal: 100, ItemType: "summary", SummaryID: summary.SummaryID, TokenCount: 20},
+ })
+
+ a := &Assembler{store: s, config: Config{}}
+ result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 1000})
+ if err != nil {
+ t.Fatalf("Assemble: %v", err)
+ }
+
+ // Summary field should contain XML with escaped special characters
+ if result.Summary == "" {
+ t.Fatal("Summary should not be empty")
+ }
+
+ // Check that special characters are escaped
+ if strings.Contains(result.Summary, "") {
+ t.Errorf("BUG: unescaped < in summary content: %q", result.Summary)
+ }
+ if strings.Contains(result.Summary, `"hello"`) {
+ t.Errorf("BUG: unescaped \" in summary content: %q", result.Summary)
+ }
+ // & should be escaped as &
+ if strings.Contains(result.Summary, " & ") {
+ t.Errorf("BUG: unescaped & in summary content: %q", result.Summary)
+ }
+}
+
+func TestAssemblerSummaryXMLWithParents(t *testing.T) {
+ s, convID := setupAssemblerStore(t)
+ ctx := context.Background()
+
+ // Create a leaf and a condensed summary (condensed has parent)
+ leaf, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "leaf content",
+ TokenCount: 20,
+ })
+ condensed, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindCondensed,
+ Depth: 1,
+ Content: "condensed content",
+ TokenCount: 15,
+ ParentIDs: []string{leaf.SummaryID},
+ })
+
+ msg, _ := s.AddMessage(ctx, convID, "user", "fresh", 5)
+
+ s.UpsertContextItems(ctx, convID, []ContextItem{
+ {Ordinal: 100, ItemType: "summary", SummaryID: condensed.SummaryID, TokenCount: 15},
+ {Ordinal: 200, ItemType: "message", MessageID: msg.ID, TokenCount: 5},
+ })
+
+ a := &Assembler{store: s, config: Config{}}
+ result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 1000})
+ if err != nil {
+ t.Fatalf("Assemble: %v", err)
+ }
+
+ // Summary field should contain XML with parent information
+ if result.Summary == "" {
+ t.Fatal("Summary should not be empty")
+ }
+ xmlContent := result.Summary
+
+ // Should contain section with parent ID
+ if !contains(xmlContent, "") {
+ t.Errorf("condensed summary XML missing section: %q", xmlContent)
+ }
+ if !contains(xmlContent, leaf.SummaryID) {
+ t.Errorf("condensed summary XML missing parent ID %q: %q", leaf.SummaryID, xmlContent)
+ }
+
+ // Should contain kind="condensed"
+ if !contains(xmlContent, `kind="condensed"`) {
+ t.Errorf("condensed summary XML missing kind attribute: %q", xmlContent)
+ }
+}
+
+func TestAssemblerSummaryXMLIncludesDescendantCount(t *testing.T) {
+ s, convID := setupAssemblerStore(t)
+ ctx := context.Background()
+
+ // Create a leaf summary with specific descendant count
+ leaf, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "leaf content",
+ TokenCount: 20,
+ DescendantCount: 8,
+ DescendantTokenCount: 1200,
+ })
+
+ msg, _ := s.AddMessage(ctx, convID, "user", "fresh", 5)
+
+ s.UpsertContextItems(ctx, convID, []ContextItem{
+ {Ordinal: 100, ItemType: "summary", SummaryID: leaf.SummaryID, TokenCount: 20},
+ {Ordinal: 200, ItemType: "message", MessageID: msg.ID, TokenCount: 5},
+ })
+
+ a := &Assembler{store: s, config: Config{}}
+ result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 1000})
+ if err != nil {
+ t.Fatalf("Assemble: %v", err)
+ }
+
+ if result.Summary == "" {
+ t.Fatal("Summary should not be empty")
+ }
+ xmlContent := result.Summary
+
+ // Should contain descendant_count="8"
+ if !contains(xmlContent, `descendant_count="8"`) {
+ t.Errorf("summary XML missing descendant_count attribute: %q", xmlContent)
+ }
+}
+
+func TestAssemblerLeafSummaryNoParents(t *testing.T) {
+ s, convID := setupAssemblerStore(t)
+ ctx := context.Background()
+
+ // Leaf summary has no parents
+ leaf, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "leaf content",
+ TokenCount: 20,
+ })
+
+ msg, _ := s.AddMessage(ctx, convID, "user", "fresh", 5)
+
+ s.UpsertContextItems(ctx, convID, []ContextItem{
+ {Ordinal: 100, ItemType: "summary", SummaryID: leaf.SummaryID, TokenCount: 20},
+ {Ordinal: 200, ItemType: "message", MessageID: msg.ID, TokenCount: 5},
+ })
+
+ a := &Assembler{store: s, config: Config{}}
+ result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 1000})
+ if err != nil {
+ t.Fatalf("Assemble: %v", err)
+ }
+
+ if result.Summary == "" {
+ t.Fatal("Summary should not be empty")
+ }
+ xmlContent := result.Summary
+
+ // Leaf summary should NOT have section
+ if contains(xmlContent, "") {
+ t.Errorf("leaf summary XML should not have section: %q", xmlContent)
+ }
+}
+
+func TestAssemblerDepthAwarePrompt(t *testing.T) {
+ s, convID := setupAssemblerStore(t)
+ ctx := context.Background()
+
+ // Create a condensed summary (depth >= 2) to trigger full guidance
+ now := time.Now().UTC()
+ leaf, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "leaf summary",
+ TokenCount: 20,
+ EarliestAt: &now,
+ LatestAt: &now,
+ })
+ condensed, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindCondensed,
+ Depth: 2,
+ Content: "condensed summary",
+ TokenCount: 15,
+ ParentIDs: []string{leaf.SummaryID},
+ DescendantCount: 1,
+ DescendantTokenCount: 20,
+ })
+
+ msg, _ := s.AddMessage(ctx, convID, "user", "fresh", 5)
+
+ s.UpsertContextItems(ctx, convID, []ContextItem{
+ {Ordinal: 100, ItemType: "summary", SummaryID: condensed.SummaryID, TokenCount: 15},
+ {Ordinal: 200, ItemType: "message", MessageID: msg.ID, TokenCount: 5},
+ })
+
+ a := &Assembler{store: s, config: Config{}}
+ result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 1000})
+ if err != nil {
+ t.Fatalf("Assemble: %v", err)
+ }
+
+ // Should have a depth-aware prompt in Summary field
+ if result.Summary == "" {
+ t.Error("expected non-empty Summary when depth >= 2")
+ }
+ // SystemPromptAddition is embedded in Summary field
+ if !strings.Contains(result.Summary, "multi-level summarization") {
+ t.Error("Summary should contain system prompt addition about multi-level summarization")
+ }
+}
+
+func TestFormatSummaryXMLUsesSummaryRef(t *testing.T) {
+ // Spec: condensed summaries use not parentId
+ now := time.Now().UTC()
+ s := Summary{
+ SummaryID: "sum_condensed1",
+ Kind: SummaryKindCondensed,
+ Depth: 1,
+ Content: "condensed content",
+ TokenCount: 50,
+ DescendantCount: 2,
+ EarliestAt: &now,
+ LatestAt: &now,
+ }
+ parentIDs := []string{"sum_leaf1", "sum_leaf2"}
+
+ xml := FormatSummaryXML(&s, parentIDs)
+
+ // Must use per spec
+ if !contains(xml, ` `) {
+ t.Errorf("expected , got: %s", xml)
+ }
+ if !contains(xml, ` `) {
+ t.Errorf("expected , got: %s", xml)
+ }
+ // Must NOT use old tag
+ if contains(xml, "") {
+ t.Errorf("should not use tag, got: %s", xml)
+ }
+}
+
+func TestFormatSummaryXMLIncludesTimestamps(t *testing.T) {
+ // Spec: summary XML includes earliest_at and latest_at attributes
+ earliest := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC)
+ latest := time.Date(2026, 3, 15, 14, 30, 0, 0, time.UTC)
+ s := Summary{
+ SummaryID: "sum_leaf1",
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "leaf content",
+ TokenCount: 30,
+ DescendantCount: 0,
+ EarliestAt: &earliest,
+ LatestAt: &latest,
+ }
+
+ xml := FormatSummaryXML(&s, nil)
+
+ if !contains(xml, `earliest_at="2026-03-15T10:00:00Z"`) {
+ t.Errorf("missing earliest_at attribute, got: %s", xml)
+ }
+ if !contains(xml, `latest_at="2026-03-15T14:30:00Z"`) {
+ t.Errorf("missing latest_at attribute, got: %s", xml)
+ }
+}
+
+func TestFormatSummaryXMLNoTimestampsWhenNil(t *testing.T) {
+ // When EarliestAt/LatestAt are nil, attributes should be omitted
+ s := Summary{
+ SummaryID: "sum_leaf1",
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "leaf content",
+ TokenCount: 30,
+ DescendantCount: 0,
+ }
+
+ xml := FormatSummaryXML(&s, nil)
+
+ if contains(xml, "earliest_at=") {
+ t.Errorf("should not have earliest_at when nil, got: %s", xml)
+ }
+ if contains(xml, "latest_at=") {
+ t.Errorf("should not have latest_at when nil, got: %s", xml)
+ }
+}
diff --git a/pkg/seahorse/short_bench_test.go b/pkg/seahorse/short_bench_test.go
new file mode 100644
index 000000000..b7e47bcff
--- /dev/null
+++ b/pkg/seahorse/short_bench_test.go
@@ -0,0 +1,336 @@
+package seahorse
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "testing"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+// newBenchStore creates a test store for benchmarks.
+func newBenchStore(b *testing.B) (*Store, func()) {
+ b.Helper()
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ b.Fatalf("open test db: %v", err)
+ }
+ if err := runSchema(db); err != nil {
+ db.Close()
+ b.Fatalf("migration: %v", err)
+ }
+ return &Store{db: db}, func() { db.Close() }
+}
+
+// --- Ingest benchmarks ---
+
+func BenchmarkIngest_SingleMessage(b *testing.B) {
+ s, cleanup := newBenchStore(b)
+ defer cleanup()
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "bench:ingest")
+ convID := conv.ConversationID
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, err := s.AddMessage(ctx, convID, "user", "Test message content", 15)
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkIngest_BatchMessages(b *testing.B) {
+ s, cleanup := newBenchStore(b)
+ defer cleanup()
+ ctx := context.Background()
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ conv, _ := s.GetOrCreateConversation(ctx, fmt.Sprintf("bench:ingest-batch:%d", i))
+ convID := conv.ConversationID
+
+ for j := 0; j < 10; j++ {
+ added, err := s.AddMessage(ctx, convID, "user",
+ fmt.Sprintf("Message %d in batch", j), 10)
+ if err != nil {
+ b.Fatal(err)
+ }
+ s.AppendContextMessage(ctx, convID, added.ID)
+ }
+ }
+}
+
+// --- Assemble benchmarks ---
+
+func BenchmarkAssemble_MessagesOnly(b *testing.B) {
+ s, cleanup := newBenchStore(b)
+ defer cleanup()
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "bench:assemble-msgs")
+ convID := conv.ConversationID
+
+ // Add 100 messages
+ for i := 0; i < 100; i++ {
+ m, _ := s.AddMessage(ctx, convID, "user",
+ fmt.Sprintf("Message content %d with some text", i), 10)
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+
+ a := &Assembler{store: s}
+ input := AssembleInput{Budget: 50000}
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, err := a.Assemble(ctx, convID, input)
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkAssemble_WithSummaries(b *testing.B) {
+ s, cleanup := newBenchStore(b)
+ defer cleanup()
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "bench:assemble-sums")
+ convID := conv.ConversationID
+
+ now := time.Now().UTC()
+
+ // Add 10 leaf summaries
+ for i := 0; i < 10; i++ {
+ sum, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: fmt.Sprintf("Leaf summary %d", i),
+ TokenCount: 500,
+ EarliestAt: &now,
+ LatestAt: &now,
+ })
+ s.AppendContextSummary(ctx, convID, sum.SummaryID)
+ }
+
+ // Add 20 fresh messages
+ for i := 0; i < 20; i++ {
+ m, _ := s.AddMessage(ctx, convID, "user", fmt.Sprintf("Fresh message %d", i), 10)
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+
+ a := &Assembler{store: s}
+ input := AssembleInput{Budget: 10000}
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, err := a.Assemble(ctx, convID, input)
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkAssemble_BudgetEviction(b *testing.B) {
+ s, cleanup := newBenchStore(b)
+ defer cleanup()
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "bench:assemble-evict")
+ convID := conv.ConversationID
+
+ now := time.Now().UTC()
+
+ // Add 50 leaf summaries (more than budget can hold)
+ for i := 0; i < 50; i++ {
+ sum, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: fmt.Sprintf("Summary %d", i),
+ TokenCount: 300,
+ EarliestAt: &now,
+ LatestAt: &now,
+ })
+ s.AppendContextSummary(ctx, convID, sum.SummaryID)
+ }
+
+ // Add fresh tail
+ for i := 0; i < FreshTailCount; i++ {
+ m, _ := s.AddMessage(ctx, convID, "user", "fresh", 10)
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+
+ a := &Assembler{store: s}
+ input := AssembleInput{Budget: 5000} // Force eviction
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, err := a.Assemble(ctx, convID, input)
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+// --- Search (FTS5) benchmarks ---
+
+// benchSeedSummaries adds n summaries to a conversation for search benchmarks.
+func benchSeedSummaries(b *testing.B, s *Store, convID int64, n int, contentTpl string) {
+ b.Helper()
+ now := time.Now().UTC()
+ for i := 0; i < n; i++ {
+ sum, err := s.CreateSummary(context.Background(), CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: fmt.Sprintf(contentTpl, i),
+ TokenCount: 200,
+ EarliestAt: &now,
+ LatestAt: &now,
+ })
+ if err != nil {
+ b.Fatalf("create summary: %v", err)
+ }
+ s.AppendContextSummary(context.Background(), convID, sum.SummaryID)
+ }
+}
+
+func BenchmarkSearchSummaries_FTS5(b *testing.B) {
+ s, cleanup := newBenchStore(b)
+ defer cleanup()
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "bench:search-fts")
+ convID := conv.ConversationID
+
+ benchSeedSummaries(b, s, convID, 100, "Summary about database configuration and API endpoints %d")
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, err := s.SearchSummaries(ctx, SearchInput{
+ Pattern: "database",
+ Mode: "full_text",
+ ConversationID: convID,
+ })
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkSearchSummaries_Like(b *testing.B) {
+ s, cleanup := newBenchStore(b)
+ defer cleanup()
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "bench:search-like")
+ convID := conv.ConversationID
+
+ benchSeedSummaries(b, s, convID, 100, "Summary about configuration %d")
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, err := s.SearchSummaries(ctx, SearchInput{
+ Pattern: "config",
+ Mode: "like",
+ ConversationID: convID,
+ })
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkSearchMessages_FTS5(b *testing.B) {
+ s, cleanup := newBenchStore(b)
+ defer cleanup()
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "bench:search-msg-fts")
+ convID := conv.ConversationID
+
+ // Add 500 messages
+ for i := 0; i < 500; i++ {
+ m, _ := s.AddMessage(ctx, convID, "user",
+ fmt.Sprintf("User message about API and database integration %d", i), 20)
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, err := s.SearchMessages(ctx, SearchInput{
+ Pattern: "API database",
+ Mode: "full_text",
+ ConversationID: convID,
+ })
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+// --- Bootstrap benchmarks ---
+
+func BenchmarkBootstrap_Empty(b *testing.B) {
+ s, cleanup := newBenchStore(b)
+ defer cleanup()
+ ctx := context.Background()
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ conv, _ := s.GetOrCreateConversation(ctx, fmt.Sprintf("bench:bootstrap-empty:%d", i))
+ convID := conv.ConversationID
+ _ = convID // Bootstrap with empty history
+ }
+}
+
+func BenchmarkBootstrap_100Messages(b *testing.B) {
+ s, cleanup := newBenchStore(b)
+ defer cleanup()
+ ctx := context.Background()
+
+ // Prepare 100 messages
+ msgs := make([]Message, 100)
+ for i := 0; i < 100; i++ {
+ msgs[i] = Message{
+ Role: "user",
+ Content: fmt.Sprintf("Bootstrap message %d", i),
+ TokenCount: 15,
+ }
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ conv, _ := s.GetOrCreateConversation(ctx, fmt.Sprintf("bench:bootstrap-100:%d", i))
+ convID := conv.ConversationID
+
+ for _, m := range msgs {
+ added, _ := s.AddMessage(ctx, convID, m.Role, m.Content, m.TokenCount)
+ s.AppendContextMessage(ctx, convID, added.ID)
+ }
+ }
+}
+
+func BenchmarkBootstrap_500Messages(b *testing.B) {
+ s, cleanup := newBenchStore(b)
+ defer cleanup()
+ ctx := context.Background()
+
+ msgs := make([]Message, 500)
+ for i := 0; i < 500; i++ {
+ msgs[i] = Message{
+ Role: "user",
+ Content: fmt.Sprintf("Bootstrap message %d", i),
+ TokenCount: 15,
+ }
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ conv, _ := s.GetOrCreateConversation(ctx, fmt.Sprintf("bench:bootstrap-500:%d", i))
+ convID := conv.ConversationID
+
+ for _, m := range msgs {
+ added, _ := s.AddMessage(ctx, convID, m.Role, m.Content, m.TokenCount)
+ s.AppendContextMessage(ctx, convID, added.ID)
+ }
+ }
+}
diff --git a/pkg/seahorse/short_compaction.go b/pkg/seahorse/short_compaction.go
new file mode 100644
index 000000000..0dfb1330f
--- /dev/null
+++ b/pkg/seahorse/short_compaction.go
@@ -0,0 +1,898 @@
+package seahorse
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/tokenizer"
+)
+
+// CompactInput controls compaction behavior.
+type CompactInput struct {
+ Budget *int // Token budget override
+ Force bool // Force compaction even if below threshold
+}
+
+// CompactResult describes what was compacted.
+type CompactResult struct {
+ SummariesCreated []string `json:"summariesCreated"`
+ TokensSaved int `json:"tokensSaved"`
+ LeafSummaries int `json:"leafSummaries"`
+ CondensedSummaries int `json:"condensedSummaries"`
+}
+
+// NeedsCompaction returns true if context tokens >= ContextThreshold × contextWindow.
+func (e *CompactionEngine) NeedsCompaction(ctx context.Context, convID int64, contextWindow int) (bool, error) {
+ tokens, err := e.store.GetContextTokenCount(ctx, convID)
+ if err != nil {
+ return false, fmt.Errorf("get token count: %w", err)
+ }
+ threshold := int(float64(contextWindow) * ContextThreshold)
+ return tokens >= threshold, nil
+}
+
+// Close cancels the shutdown context, stopping async goroutines.
+func (e *CompactionEngine) Close() {
+ if e.shutdownCancel != nil {
+ e.shutdownCancel()
+ }
+}
+
+// Compact runs leaf compaction (sync) and optionally condensed compaction.
+func (e *CompactionEngine) Compact(ctx context.Context, convID int64, input CompactInput) (*CompactResult, error) {
+ result := &CompactResult{}
+
+ // Phase 1: leaf compaction (synchronous, every turn)
+ summaryID, err := e.compactLeaf(ctx, convID)
+ if err != nil {
+ return nil, fmt.Errorf("compact leaf: %w", err)
+ }
+ if summaryID != nil {
+ result.SummariesCreated = append(result.SummariesCreated, *summaryID)
+ result.LeafSummaries++
+ logger.InfoCF("seahorse", "compact: leaf", map[string]any{
+ "conv_id": convID,
+ "summary_id": *summaryID,
+ })
+ }
+
+ // Phase 2: condensed compaction if over threshold
+ tokensBefore, _ := e.store.GetContextTokenCount(ctx, convID)
+ var budget int
+ if input.Budget != nil {
+ budget = *input.Budget
+ if budget == 0 {
+ logger.ErrorCF("seahorse", "Compact: budget is 0, this should not happen", map[string]any{
+ "conv_id": convID,
+ })
+ }
+ } else {
+ budget = int(float64(tokensBefore) * ContextThreshold)
+ }
+
+ if input.Force || (tokensBefore > budget && budget > 0) {
+ // Launch async condensed compaction with dedup
+ if _, loaded := e.condensing.LoadOrStore(convID, struct{}{}); !loaded {
+ go func() {
+ defer e.condensing.Delete(convID)
+ e.runCondensedLoop(e.shutdownCtx, convID)
+ }()
+ }
+ }
+
+ tokensAfter, _ := e.store.GetContextTokenCount(ctx, convID)
+ if tokensAfter < tokensBefore {
+ result.TokensSaved = tokensBefore - tokensAfter
+ }
+
+ return result, nil
+}
+
+// CompactUntilUnder aggressively compacts until context is under budget.
+func (e *CompactionEngine) CompactUntilUnder(ctx context.Context, convID int64, budget int) (*CompactResult, error) {
+ result := &CompactResult{}
+ prevTokens := 0
+ logger.InfoCF("seahorse", "compact_until_under: start", map[string]any{"conv_id": convID, "budget": budget})
+
+ for iter := 0; iter < MaxCompactIterations; iter++ {
+ tokens, err := e.store.GetContextTokenCount(ctx, convID)
+ if err != nil {
+ return result, fmt.Errorf("get tokens: %w", err)
+ }
+ if tokens <= budget {
+ logger.InfoCF("seahorse", "compact_until_under: done", map[string]any{
+ "conv_id": convID,
+ "budget": budget,
+ "tokens": tokens,
+ "leaf": result.LeafSummaries,
+ "condensed": result.CondensedSummaries,
+ })
+ return result, nil
+ }
+
+ // Try leaf first
+ summaryID, err := e.compactLeaf(ctx, convID, true)
+ if err != nil {
+ return result, err
+ }
+ if summaryID != nil {
+ result.SummariesCreated = append(result.SummariesCreated, *summaryID)
+ result.LeafSummaries++
+ logger.InfoCF("seahorse", "compact_until_under: leaf", map[string]any{
+ "conv_id": convID,
+ "summary_id": *summaryID,
+ })
+ continue
+ }
+
+ // Try condensed with forced fanout
+ condensedID, err := e.compactCondensed(ctx, convID)
+ if err != nil {
+ return result, err
+ }
+ if condensedID != nil {
+ result.SummariesCreated = append(result.SummariesCreated, *condensedID)
+ result.CondensedSummaries++
+ logger.InfoCF("seahorse", "compact_until_under: condensed", map[string]any{
+ "conv_id": convID,
+ "summary_id": *condensedID,
+ })
+ continue
+ }
+
+ // No progress
+ newTokens, _ := e.store.GetContextTokenCount(ctx, convID)
+ if newTokens >= prevTokens {
+ logger.WarnCF("seahorse", "compact_until_under: no progress", map[string]any{
+ "conv_id": convID,
+ "tokens": newTokens,
+ })
+ return result, nil
+ }
+ prevTokens = newTokens
+ }
+
+ // Safety cap exceeded — see MaxCompactIterations doc for rationale.
+ logger.WarnCF("seahorse", "compact_until_under: exceeded max iterations", map[string]any{
+ "conv_id": convID,
+ "budget": budget,
+ "iterations": MaxCompactIterations,
+ "tokens": prevTokens,
+ })
+ return result, nil
+}
+
+// compactLeaf compresses the oldest contiguous message chunk into a leaf summary.
+// When force is true, FreshTailCount protection is bypassed (used by CompactUntilUnder).
+func (e *CompactionEngine) compactLeaf(ctx context.Context, convID int64, force ...bool) (*string, error) {
+ items, err := e.store.GetContextItems(ctx, convID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Find oldest contiguous message chunk outside fresh tail
+ msgCount := 0
+ msgTokens := 0
+ for _, item := range items {
+ if item.ItemType == "message" {
+ msgCount++
+ msgTokens += item.TokenCount
+ }
+ }
+
+ // Trigger if either message count or token threshold is met
+ if msgCount < LeafMinFanout && msgTokens < LeafChunkTokens {
+ return nil, nil
+ }
+
+ // Calculate fresh tail boundary (bypass when forced)
+ useForce := len(force) > 0 && force[0]
+ tailStartIdx := len(items) - FreshTailCount
+ if useForce {
+ tailStartIdx = len(items) // allow compacting everything
+ }
+ if tailStartIdx < 0 {
+ tailStartIdx = 0
+ }
+
+ // Find oldest contiguous message chunk, accumulating up to LeafChunkTokens
+ var chunk []ContextItem
+ chunkStart := -1
+ chunkEnd := -1
+ accumTokens := 0
+ for i := 0; i < tailStartIdx; i++ {
+ if items[i].ItemType == "message" {
+ if chunkStart == -1 {
+ chunkStart = i
+ }
+ chunkEnd = i
+ accumTokens += items[i].TokenCount
+ // Stop accumulating once we reach the token budget
+ if accumTokens >= LeafChunkTokens {
+ break
+ }
+ } else {
+ // Non-message breaks the chunk
+ if chunkStart != -1 && (chunkEnd-chunkStart+1) >= LeafMinFanout {
+ break
+ }
+ chunkStart = -1
+ chunkEnd = -1
+ accumTokens = 0
+ }
+ }
+
+ if chunkStart == -1 || (chunkEnd-chunkStart+1) < LeafMinFanout {
+ return nil, nil
+ }
+
+ chunk = items[chunkStart : chunkEnd+1]
+
+ // Collect messages for the chunk
+ var messages []Message
+ for _, item := range chunk {
+ msg, innerErr := e.store.GetMessageByID(ctx, item.MessageID)
+ if innerErr != nil {
+ return nil, innerErr
+ }
+ messages = append(messages, *msg)
+ }
+
+ // Get prior summaries for context
+ priorSummary := ""
+ priorCount := 0
+ for i := chunkStart - 1; i >= 0 && priorCount < 2; i-- {
+ if items[i].ItemType == "summary" {
+ sum, innerErr2 := e.store.GetSummary(ctx, items[i].SummaryID)
+ if innerErr2 == nil {
+ priorSummary = sum.Content + "\n" + priorSummary
+ priorCount++
+ }
+ }
+ }
+
+ // Generate summary
+ content, err := e.generateLeafSummary(ctx, messages, priorSummary)
+ if err != nil {
+ return nil, err
+ }
+
+ // Create summary in store
+ tokenCount := tokenizer.EstimateMessageTokens(providers.Message{Content: content})
+
+ var earliestAt, latestAt *time.Time
+ if len(messages) > 0 {
+ earliestAt = &messages[0].CreatedAt
+ latestAt = &messages[len(messages)-1].CreatedAt
+ }
+
+ summary, err := e.store.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: content,
+ TokenCount: tokenCount,
+ EarliestAt: earliestAt,
+ LatestAt: latestAt,
+ SourceMessageTokens: sumMessageTokens(messages),
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ // Link to source messages
+ msgIDs := make([]int64, len(messages))
+ for i, m := range messages {
+ msgIDs[i] = m.ID
+ }
+ if err := e.store.LinkSummaryToMessages(ctx, summary.SummaryID, msgIDs); err != nil {
+ return nil, err
+ }
+
+ // Replace context range with summary
+ if err := e.store.ReplaceContextRangeWithSummary(
+ ctx, convID, chunk[0].Ordinal, chunk[len(chunk)-1].Ordinal, summary.SummaryID,
+ ); err != nil {
+ return nil, err
+ }
+
+ return &summary.SummaryID, nil
+}
+
+// compactCondensed compresses multiple summaries into one higher-level summary.
+func (e *CompactionEngine) compactCondensed(ctx context.Context, convID int64) (*string, error) {
+ // Try ordinal-aware selection first (respects consecutive ordering)
+ var candidates []Summary
+
+ depths, err := e.store.GetDistinctDepthsInContext(ctx, convID, 0)
+ if err != nil {
+ return nil, err
+ }
+ for _, depth := range depths {
+ var chunkAtDepth []Summary
+ var err2 error
+ chunkAtDepth, err2 = e.selectOldestChunkAtDepth(ctx, convID, depth)
+ if err2 != nil {
+ continue
+ }
+ if len(chunkAtDepth) > 0 {
+ candidates = chunkAtDepth
+ break
+ }
+ }
+
+ // Fallback to depth-grouping selection
+ if len(candidates) == 0 {
+ candidates, err = e.selectShallowestCondensationCandidate(ctx, convID, false)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if len(candidates) == 0 {
+ return nil, nil
+ }
+
+ // Generate condensed summary
+ content, err := e.generateCondensedSummary(ctx, candidates)
+ if err != nil {
+ return nil, err
+ }
+
+ // Merge metadata
+ maxDepth := 0
+ descendantCount := 0
+ descendantTokenCount := 0
+ sourceMessageTokens := 0
+ var earliestAt, latestAt *time.Time
+
+ parentIDs := make([]string, len(candidates))
+ for i, c := range candidates {
+ parentIDs[i] = c.SummaryID
+ if c.Depth > maxDepth {
+ maxDepth = c.Depth
+ }
+ descendantCount += c.DescendantCount + 1
+ descendantTokenCount += c.TokenCount + c.DescendantTokenCount
+ sourceMessageTokens += c.SourceMessageTokenCount
+ if c.EarliestAt != nil {
+ if earliestAt == nil || c.EarliestAt.Before(*earliestAt) {
+ earliestAt = c.EarliestAt
+ }
+ }
+ if c.LatestAt != nil {
+ if latestAt == nil || c.LatestAt.After(*latestAt) {
+ latestAt = c.LatestAt
+ }
+ }
+ }
+
+ tokenCount := tokenizer.EstimateMessageTokens(providers.Message{Content: content})
+
+ summary, err := e.store.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindCondensed,
+ Depth: maxDepth + 1,
+ Content: content,
+ TokenCount: tokenCount,
+ EarliestAt: earliestAt,
+ LatestAt: latestAt,
+ DescendantCount: descendantCount,
+ DescendantTokenCount: descendantTokenCount,
+ SourceMessageTokens: sourceMessageTokens,
+ ParentIDs: parentIDs,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ // Find the ordinal range for the candidate summaries in context
+ items, err := e.store.GetContextItems(ctx, convID)
+ if err != nil {
+ return nil, err
+ }
+
+ candidateSet := make(map[string]bool)
+ for _, c := range candidates {
+ candidateSet[c.SummaryID] = true
+ }
+
+ startOrd := -1
+ endOrd := -1
+ hasNonCandidate := false
+ for _, item := range items {
+ if item.ItemType == "summary" && candidateSet[item.SummaryID] {
+ if startOrd == -1 {
+ startOrd, endOrd = item.Ordinal, item.Ordinal
+ } else {
+ // Check for non-candidate items between endOrd and current ordinal
+ for _, it := range items {
+ if it.Ordinal > endOrd && it.Ordinal <= item.Ordinal {
+ if it.ItemType != "summary" || !candidateSet[it.SummaryID] {
+ hasNonCandidate = true
+ break
+ }
+ }
+ }
+ if hasNonCandidate {
+ break
+ }
+ if item.Ordinal < startOrd {
+ startOrd = item.Ordinal
+ }
+ if item.Ordinal > endOrd {
+ endOrd = item.Ordinal
+ }
+ }
+ }
+ }
+
+ if startOrd == -1 || endOrd == -1 {
+ return nil, nil
+ }
+
+ // Collect candidate summary IDs
+ candidateIDs := make([]string, 0, len(candidates))
+ for _, c := range candidates {
+ candidateIDs = append(candidateIDs, c.SummaryID)
+ }
+
+ if hasNonCandidate {
+ // Use safe per-item deletion to avoid deleting non-candidate items
+ if err := e.store.ReplaceContextItemsWithSummary(ctx, convID, candidateIDs, summary.SummaryID); err != nil {
+ return nil, err
+ }
+ } else {
+ // Candidates are consecutive, use efficient range deletion
+ if err := e.store.ReplaceContextRangeWithSummary(ctx, convID, startOrd, endOrd, summary.SummaryID); err != nil {
+ return nil, err
+ }
+ }
+
+ return &summary.SummaryID, nil
+}
+
+// selectShallowestCondensationCandidate finds the shallowest consecutive summary group.
+func (e *CompactionEngine) selectShallowestCondensationCandidate(
+ ctx context.Context, convID int64, forced bool,
+) ([]Summary, error) {
+ items, err := e.store.GetContextItems(ctx, convID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Group by depth, find consecutive runs
+ tailStartIdx := len(items) - FreshTailCount
+ if tailStartIdx < 0 {
+ tailStartIdx = 0
+ }
+
+ minFanout := CondensedMinFanout
+ if forced {
+ minFanout = CondensedMinFanoutHard
+ }
+
+ // Track depth groups
+ depthGroups := make(map[int][]ContextItem)
+ for i := 0; i < tailStartIdx; i++ {
+ item := items[i]
+ if item.ItemType != "summary" {
+ continue
+ }
+ sum, err := e.store.GetSummary(ctx, item.SummaryID)
+ if err != nil {
+ continue
+ }
+ depthGroups[sum.Depth] = append(depthGroups[sum.Depth], item)
+ }
+
+ // Find shallowest depth with enough candidates
+ // Collect all depths and sort to handle non-consecutive depths
+ var depths []int
+ for depth := range depthGroups {
+ depths = append(depths, depth)
+ }
+ sort.Ints(depths)
+
+ for _, depth := range depths {
+ group := depthGroups[depth]
+ if len(group) >= minFanout {
+ // Load summaries
+ var result []Summary
+ for _, item := range group[:minFanout] {
+ sum, err := e.store.GetSummary(ctx, item.SummaryID)
+ if err != nil {
+ continue
+ }
+ result = append(result, *sum)
+ }
+ return result, nil
+ }
+ }
+
+ return nil, nil
+}
+
+// selectOldestChunkAtDepth scans context_items from oldest ordinal, collecting consecutive
+// summaries at the given depth. Stops at non-summary items, different depth, fresh tail, or
+// token overflow. Returns contiguous chunk of summaries.
+func (e *CompactionEngine) selectOldestChunkAtDepth(
+ ctx context.Context, convID int64, targetDepth int,
+) ([]Summary, error) {
+ items, err := e.store.GetContextItems(ctx, convID)
+ if err != nil {
+ return nil, err
+ }
+
+ tailStartIdx := len(items) - FreshTailCount
+ if tailStartIdx < 0 {
+ tailStartIdx = 0
+ }
+
+ var chunk []Summary
+ accumTokens := 0
+
+ for i := 0; i < tailStartIdx; i++ {
+ item := items[i]
+ if item.ItemType != "summary" {
+ // Non-summary breaks the chunk
+ break
+ }
+ sum, err := e.store.GetSummary(ctx, item.SummaryID)
+ if err != nil {
+ break
+ }
+ if sum.Depth != targetDepth {
+ // Different depth breaks the chunk
+ break
+ }
+ if accumTokens+sum.TokenCount > LeafChunkTokens {
+ // Token overflow stops collection
+ break
+ }
+ chunk = append(chunk, *sum)
+ accumTokens += sum.TokenCount
+ }
+
+ // Min tokens check: spec line 808
+ // chunk tokens must be >= max(CondensedTargetTokens, LeafChunkTokens × 0.1) = 2000
+ minTokens := CondensedTargetTokens // 2000
+ if accumTokens < minTokens {
+ return nil, nil
+ }
+
+ return chunk, nil
+}
+
+// generateLeafSummary calls the LLM to generate a leaf summary with 3-level escalation.
+// Level 1: normal LLM prompt. Level 2: aggressive prompt. Level 3: deterministic truncation.
+func (e *CompactionEngine) generateLeafSummary(
+ ctx context.Context,
+ messages []Message,
+ previousSummary string,
+) (string, error) {
+ if e.complete == nil {
+ return truncateSummary(messages), nil
+ }
+
+ sourceText := formatMessagesForSummary(messages)
+ inputTokens := sumMessageTokens(messages)
+ targetTokens := minInt(LeafTargetTokens, int(float64(inputTokens)*0.35))
+
+ // Level 1: normal prompt
+ prompt := buildLeafSummaryPrompt(sourceText, previousSummary, targetTokens)
+ content, err := e.complete(ctx, prompt, CompleteOptions{
+ MaxTokens: LeafTargetTokens * 2,
+ Temperature: 0.3,
+ })
+ if err != nil {
+ return "", err
+ }
+ if content == "" {
+ // Retry with temperature=0
+ content, err = e.complete(ctx, prompt, CompleteOptions{
+ MaxTokens: LeafTargetTokens * 2,
+ Temperature: 0,
+ })
+ if err != nil {
+ return "", err
+ }
+ }
+
+ // Level 1 only succeeds if it actually reaches the requested target size.
+ if content != "" && tokenizer.EstimateMessageTokens(providers.Message{Content: content}) <= targetTokens {
+ return content, nil
+ }
+
+ // Level 2: aggressive prompt
+ aggressiveTarget := minInt(640, int(float64(inputTokens)*0.20))
+ aggressivePrompt := buildAggressiveLeafSummaryPrompt(sourceText, previousSummary, aggressiveTarget)
+ content, err = e.complete(ctx, aggressivePrompt, CompleteOptions{
+ MaxTokens: aggressiveTarget * 2,
+ Temperature: 0.3,
+ })
+ if err != nil {
+ return "", err
+ }
+ if content == "" {
+ // Retry with temperature=0
+ content, err = e.complete(ctx, aggressivePrompt, CompleteOptions{
+ MaxTokens: aggressiveTarget * 2,
+ Temperature: 0,
+ })
+ if err != nil {
+ return "", err
+ }
+ }
+ if content != "" && tokenizer.EstimateMessageTokens(providers.Message{Content: content}) <= aggressiveTarget {
+ return content, nil
+ }
+
+ // Level 3: deterministic truncation
+ return truncateSummary(messages), nil
+}
+
+// generateCondensedSummary calls the LLM to generate a condensed summary with 3-level escalation.
+func (e *CompactionEngine) generateCondensedSummary(ctx context.Context, summaries []Summary) (string, error) {
+ if e.complete == nil {
+ return truncateCondensedSummaries(summaries), nil
+ }
+
+ sourceText := formatSummariesForCondensation(summaries)
+ inputTokens := sumSummaryTokens(summaries)
+ targetTokens := minInt(CondensedTargetTokens, int(float64(inputTokens)*0.35))
+
+ // Level 1: normal prompt
+ prompt := buildCondensedSummaryPrompt(sourceText, targetTokens)
+ content, err := e.complete(ctx, prompt, CompleteOptions{
+ MaxTokens: CondensedTargetTokens * 2,
+ Temperature: 0.3,
+ })
+ if err != nil {
+ return "", err
+ }
+ if content == "" {
+ content, err = e.complete(ctx, prompt, CompleteOptions{
+ MaxTokens: CondensedTargetTokens * 2,
+ Temperature: 0,
+ })
+ if err != nil {
+ return "", err
+ }
+ }
+ if content != "" {
+ return content, nil
+ }
+
+ // Level 2: aggressive prompt
+ aggressiveTarget := minInt(640, int(float64(inputTokens)*0.20))
+ aggressivePrompt := buildCondensedSummaryPrompt(sourceText, aggressiveTarget)
+ content, err = e.complete(ctx, aggressivePrompt, CompleteOptions{
+ MaxTokens: aggressiveTarget * 2,
+ Temperature: 0.3,
+ })
+ if err != nil {
+ return "", err
+ }
+ if content != "" {
+ return content, nil
+ }
+
+ // Level 3: deterministic fallback
+ return truncateCondensedSummaries(summaries), nil
+}
+
+// runCondensedLoop runs condensed compaction in a loop until:
+// a) context tokens <= threshold (success), OR
+// b) No candidate found (nothing to condense), OR
+// c) tokensAfter >= tokensBefore (no progress this iteration), OR
+// d) tokensAfter >= previousTokens (no improvement over last iteration)
+func (e *CompactionEngine) runCondensedLoop(ctx context.Context, convID int64) {
+ var prevTokens int
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+
+ tokensBefore, err := e.store.GetContextTokenCount(ctx, convID)
+ if err != nil {
+ logger.ErrorCF("seahorse", "condensed: get tokens", map[string]any{"error": err.Error()})
+ return
+ }
+
+ condensedID, err := e.compactCondensed(ctx, convID)
+ if err != nil {
+ logger.ErrorCF("seahorse", "condensed: compact", map[string]any{"error": err.Error()})
+ return
+ }
+ if condensedID == nil {
+ // No candidate found
+ logger.DebugCF("seahorse", "condensed: no candidate", map[string]any{"conv_id": convID})
+ return
+ }
+
+ tokensAfter, _ := e.store.GetContextTokenCount(ctx, convID)
+
+ if tokensAfter >= tokensBefore {
+ // No progress this iteration
+ logger.DebugCF(
+ "seahorse",
+ "condensed: no progress",
+ map[string]any{"conv_id": convID, "tokens_before": tokensBefore, "tokens_after": tokensAfter},
+ )
+ return
+ }
+ if tokensAfter >= prevTokens && prevTokens > 0 {
+ // No improvement over last iteration
+ logger.DebugCF(
+ "seahorse",
+ "condensed: no improvement",
+ map[string]any{"conv_id": convID, "tokens": tokensAfter},
+ )
+ return
+ }
+
+ prevTokens = tokensAfter
+ }
+}
+
+// --- Helper functions ---
+
+func formatMessagesForSummary(messages []Message) string {
+ var result string
+ for _, m := range messages {
+ ts := m.CreatedAt.Format("2006-01-02 15:04 MST")
+ content := m.Content
+ if content == "" && len(m.Parts) > 0 {
+ content = partsToReadableContent(m.Parts)
+ }
+ result += fmt.Sprintf("[%s]\n%s\n\n", ts, content)
+ }
+ return result
+}
+
+func formatSummariesForCondensation(summaries []Summary) string {
+ var result string
+ for _, s := range summaries {
+ earliest := ""
+ if s.EarliestAt != nil {
+ earliest = s.EarliestAt.Format("2006-01-02")
+ }
+ latest := ""
+ if s.LatestAt != nil {
+ latest = s.LatestAt.Format("2006-01-02")
+ }
+ result += fmt.Sprintf("[%s - %s]\n%s\n\n", earliest, latest, s.Content)
+ }
+ return result
+}
+
+func buildLeafSummaryPrompt(sourceText, previousSummary string, targetTokens int) string {
+ prev := "(none)"
+ if previousSummary != "" {
+ prev = previousSummary
+ }
+ return fmt.Sprintf(`You summarize a SEGMENT of a conversation for future model turns.
+Treat this as incremental memory compaction input, not a full-conversation summary.
+
+Normal summary policy:
+- Preserve key decisions, rationale, constraints, and active tasks.
+- Keep essential technical details needed to continue work safely.
+- Remove obvious repetition and conversational filler.
+
+Output requirements:
+- Plain text only.
+- No preamble, headings, or markdown formatting.
+- Track file operations (created, modified, deleted, renamed) with file paths and current status.
+- If no file operations appear, include exactly: "Files: none".
+- End with exactly: "Expand for details about: ".
+- Target length: about %d tokens or less.
+
+
+%s
+
+
+
+%s
+ `, targetTokens, prev, sourceText)
+}
+
+func buildCondensedSummaryPrompt(sourceText string, targetTokens int) string {
+ return fmt.Sprintf(`You condense multiple summaries into a single higher-level summary.
+Preserve all important decisions, constraints, and outcomes.
+Merge overlapping topics. Keep technical details intact.
+
+Output requirements:
+- Plain text only.
+- No preamble, headings, or markdown formatting.
+- End with exactly: "Expand for details about: ".
+- Target length: about %d tokens or less.
+
+
+%s
+ `, targetTokens, sourceText)
+}
+
+func buildAggressiveLeafSummaryPrompt(sourceText, previousSummary string, targetTokens int) string {
+ prev := "(none)"
+ if previousSummary != "" {
+ prev = previousSummary
+ }
+ return fmt.Sprintf(`You summarize a SEGMENT of a conversation for future model turns.
+Aggressive summary policy:
+- Keep only durable facts and current task state.
+- Remove examples, repetition, and low-value narrative details.
+- Preserve explicit TODOs, blockers, decisions, and constraints.
+
+Output requirements:
+- Plain text only.
+- No preamble, headings, or markdown formatting.
+- Track file operations (created, modified, deleted, renamed) with file paths and current status.
+- If no file operations appear, include exactly: "Files: none".
+- End with exactly: "Expand for details about: ".
+- Target length: about %d tokens or less.
+
+
+%s
+
+
+
+%s
+ `, targetTokens, prev, sourceText)
+}
+
+func truncateSummary(messages []Message) string {
+ content := ""
+ for _, m := range messages {
+ c := m.Content
+ if c == "" && len(m.Parts) > 0 {
+ c = partsToReadableContent(m.Parts)
+ }
+ content += c + "\n"
+ }
+ if len(content) > 2048 {
+ content = content[:2048]
+ }
+ content += fmt.Sprintf("\n[Truncated from %d messages]", len(messages))
+ return content
+}
+
+func truncateCondensedSummaries(summaries []Summary) string {
+ content := ""
+ for _, s := range summaries {
+ content += s.Content + "\n"
+ }
+ if len(content) > 2048 {
+ content = content[:2048]
+ }
+ content += fmt.Sprintf("\n[Condensed from %d summaries]", len(summaries))
+ return content
+}
+
+func sumMessageTokens(messages []Message) int {
+ total := 0
+ for _, m := range messages {
+ total += m.TokenCount
+ }
+ return total
+}
+
+func sumSummaryTokens(summaries []Summary) int {
+ total := 0
+ for _, s := range summaries {
+ total += s.TokenCount
+ }
+ return total
+}
+
+func minInt(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/pkg/seahorse/short_compaction_test.go b/pkg/seahorse/short_compaction_test.go
new file mode 100644
index 000000000..da07cdab7
--- /dev/null
+++ b/pkg/seahorse/short_compaction_test.go
@@ -0,0 +1,1038 @@
+package seahorse
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// --- Test Helpers ---
+
+// waitForCondensed blocks until the async condensed goroutine for convID finishes.
+// Returns false if timeout is reached.
+func waitForCondensed(ce *CompactionEngine, convID int64, timeout time.Duration) bool {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ if _, exists := ce.condensing.Load(convID); !exists {
+ return true
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ return false
+}
+
+// --- Compaction Tests ---
+
+func newTestCompactionEngine(t *testing.T) (*CompactionEngine, *Store, int64) {
+ t.Helper()
+ db := openTestDB(t)
+ if err := runSchema(db); err != nil {
+ t.Fatalf("migration: %v", err)
+ }
+ s := &Store{db: db}
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "test:compact")
+ shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
+ ce := &CompactionEngine{
+ store: s,
+ config: Config{},
+ complete: mockCompleteFn,
+ shutdownCtx: shutdownCtx,
+ shutdownCancel: shutdownCancel,
+ }
+ convID := conv.ConversationID
+ // Ensure async goroutines are stopped before database is closed.
+ // Register cleanup here (after openTestDB) so it runs BEFORE openTestDB's db.Close().
+ t.Cleanup(func() {
+ shutdownCancel()
+ // Wait for async condensed goroutine to finish (poll condensing map)
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if _, exists := ce.condensing.Load(convID); !exists {
+ break
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ })
+ return ce, s, conv.ConversationID
+}
+
+// newTestCompactionEngineWithStore creates a CompactionEngine with existing store.
+// Note: Caller is responsible for calling shutdownCancel when test ends.
+func newTestCompactionEngineWithStore(
+ s *Store, complete CompleteFn,
+) (ce *CompactionEngine, shutdownCancel context.CancelFunc) {
+ shutdownCtx, cancel := context.WithCancel(context.Background())
+ return &CompactionEngine{
+ store: s,
+ config: Config{},
+ complete: complete,
+ shutdownCtx: shutdownCtx,
+ shutdownCancel: cancel,
+ }, cancel
+}
+
+// mockCompleteFn returns a simple summary for testing
+var mockCompleteFn CompleteFn = func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) {
+ return "Mock summary of the conversation segment.", nil
+}
+
+func TestNeedsCompaction(t *testing.T) {
+ ce, s, convID := newTestCompactionEngine(t)
+ ctx := context.Background()
+
+ // Empty context — no compaction needed
+ needed, err := ce.NeedsCompaction(ctx, convID, 10000)
+ if err != nil {
+ t.Fatalf("NeedsCompaction: %v", err)
+ }
+ if needed {
+ t.Error("expected no compaction for empty context")
+ }
+
+ // Add messages to context, total tokens = 8000
+ for i := 0; i < 8; i++ {
+ m, _ := s.AddMessage(ctx, convID, "user", "test message content", 1000)
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+
+ // Threshold = 0.75 × 10000 = 7500. We have 8000 tokens → needs compaction
+ needed, err = ce.NeedsCompaction(ctx, convID, 10000)
+ if err != nil {
+ t.Fatalf("NeedsCompaction: %v", err)
+ }
+ if !needed {
+ t.Error("expected compaction needed at 8000/10000 tokens (threshold 75%)")
+ }
+
+ // Below threshold: 5000 / 10000 → no compaction
+ s.UpsertContextItems(ctx, convID, nil) // clear
+ for i := 0; i < 5; i++ {
+ m, _ := s.AddMessage(ctx, convID, "user", "test", 1000)
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+ needed, _ = ce.NeedsCompaction(ctx, convID, 10000)
+ if needed {
+ t.Error("expected no compaction at 5000/10000 tokens")
+ }
+}
+
+func TestCompactLeaf(t *testing.T) {
+ ce, s, convID := newTestCompactionEngine(t)
+ ctx := context.Background()
+
+ // Create enough messages to trigger leaf compaction:
+ // Need > FreshTailCount(32) evictable messages with >= LeafMinFanout(8) contiguous
+ for i := 0; i < 40; i++ {
+ m, _ := s.AddMessage(ctx, convID, "user", "message content for compaction test", 100)
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+
+ // Compact
+ result, err := ce.Compact(ctx, convID, CompactInput{})
+ if err != nil {
+ t.Fatalf("Compact: %v", err)
+ }
+ if result == nil {
+ t.Fatal("expected non-nil result")
+ }
+
+ // Should have created at least one leaf summary
+ if result.LeafSummaries == 0 {
+ t.Error("expected at least 1 leaf summary")
+ }
+
+ // Context should now contain a summary item
+ items, _ := s.GetContextItems(ctx, convID)
+ foundSummary := false
+ for _, item := range items {
+ if item.ItemType == "summary" {
+ foundSummary = true
+ break
+ }
+ }
+ if !foundSummary {
+ t.Error("expected a summary in context_items after leaf compaction")
+ }
+
+ // Some messages should have been replaced
+ if len(result.SummariesCreated) == 0 {
+ t.Error("expected at least 1 summary created")
+ }
+}
+
+func TestCompactLeafNoCandidate(t *testing.T) {
+ ce, _, convID := newTestCompactionEngine(t)
+ ctx := context.Background()
+
+ // Too few messages to trigger leaf compaction
+ m, _ := ce.store.AddMessage(ctx, convID, "user", "short", 10)
+ ce.store.AppendContextMessage(ctx, convID, m.ID)
+
+ result, err := ce.Compact(ctx, convID, CompactInput{})
+ if err != nil {
+ t.Fatalf("Compact: %v", err)
+ }
+ if result == nil {
+ t.Fatal("expected non-nil result even with no candidate")
+ }
+ if result.LeafSummaries != 0 {
+ t.Errorf("LeafSummaries = %d, want 0 (too few messages)", result.LeafSummaries)
+ }
+}
+
+func TestCompactCondensed(t *testing.T) {
+ ce, s, convID := newTestCompactionEngine(t)
+ ctx := context.Background()
+
+ // Create enough leaf summaries and fresh messages to enable condensation
+ leafIDs := make([]string, CondensedMinFanout)
+ for i := 0; i < CondensedMinFanout; i++ {
+ now := time.Now().UTC()
+ summary, err := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "leaf summary content " + time.Now().String(),
+ TokenCount: 500,
+ EarliestAt: &now,
+ LatestAt: &now,
+ })
+ if err != nil {
+ t.Fatalf("CreateSummary %d: %v", i, err)
+ }
+ leafIDs[i] = summary.SummaryID
+ s.AppendContextSummary(ctx, convID, summary.SummaryID)
+ }
+
+ // Add enough fresh messages to have a fresh tail (>= FreshTailCount)
+ for i := 0; i < FreshTailCount; i++ {
+ m, _ := s.AddMessage(ctx, convID, "user", "fresh message", 10)
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+
+ // Compact with force to trigger condensation
+ _, err := ce.Compact(ctx, convID, CompactInput{Force: true})
+ if err != nil {
+ t.Fatalf("Compact: %v", err)
+ }
+
+ // Wait for async condensed goroutine to complete
+ if !waitForCondensed(ce, convID, 2*time.Second) {
+ t.Fatal("timeout waiting for condensed compaction")
+ }
+
+ // Should have created a condensed summary in the DB
+ summaries, _ := s.GetSummariesByConversation(ctx, convID)
+ foundCondensed := false
+ for _, sum := range summaries {
+ if sum.Kind == SummaryKindCondensed {
+ foundCondensed = true
+ break
+ }
+ }
+ if !foundCondensed {
+ t.Error("expected at least 1 condensed summary")
+ }
+}
+
+func TestCompactCondensedDoesNotOrphanSummaryWhenCandidatesRemovedConcurrently(t *testing.T) {
+ // Reproduce orphan bug: candidates found by selectOldestChunkAtDepth are removed
+ // from context_items between candidate selection and ordinal range scan.
+ // Use a slow CompleteFn with barrier sync to control timing.
+ s := openTestStore(t)
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "test:orphan-race")
+ convID := conv.ConversationID
+
+ // Create leaf summaries with enough tokens for condensation
+ var leafIDs []string
+ for i := 0; i < CondensedMinFanout; i++ {
+ now := time.Now().UTC()
+ sum, err := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: fmt.Sprintf("leaf summary %d", i),
+ TokenCount: 500,
+ EarliestAt: &now,
+ LatestAt: &now,
+ })
+ if err != nil {
+ t.Fatalf("CreateSummary: %v", err)
+ }
+ leafIDs = append(leafIDs, sum.SummaryID)
+ s.AppendContextSummary(ctx, convID, sum.SummaryID)
+ }
+
+ // Add fresh tail so leaf summaries are in evictable range
+ for i := 0; i < FreshTailCount+1; i++ {
+ m, _ := s.AddMessage(ctx, convID, "user", "fresh", 10)
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+
+ // Barrier: CompleteFn waits until test removes context_items, then returns
+ var barrier1, barrier2 sync.WaitGroup
+ barrier1.Add(1) // CompleteFn signals when called
+ barrier2.Add(1) // test signals when context_items removed
+
+ slowComplete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) {
+ barrier1.Done() // signal: LLM called, candidates selected
+ barrier2.Wait() // wait: test removes context_items
+ return "Condensed summary.", nil
+ }
+
+ ce, cancel := newTestCompactionEngineWithStore(s, slowComplete)
+ t.Cleanup(func() {
+ cancel()
+ time.Sleep(100 * time.Millisecond)
+ })
+
+ // Run compactCondensed in background
+ type compactResult struct {
+ summaryID *string
+ err error
+ }
+ resultCh := make(chan compactResult, 1)
+ go func() {
+ sid, err := ce.compactCondensed(context.Background(), convID)
+ resultCh <- compactResult{summaryID: sid, err: err}
+ }()
+
+ // Wait for CompleteFn to be called (candidates selected)
+ barrier1.Wait()
+
+ // Remove leaf summaries from context_items (simulating concurrent replacement)
+ items, _ := s.GetContextItems(ctx, convID)
+ var preserved []ContextItem
+ for _, item := range items {
+ isLeaf := false
+ for _, lid := range leafIDs {
+ if item.SummaryID == lid {
+ isLeaf = true
+ break
+ }
+ }
+ if !isLeaf {
+ preserved = append(preserved, item)
+ }
+ }
+ s.UpsertContextItems(ctx, convID, preserved)
+
+ // Let CompleteFn return
+ barrier2.Done()
+
+ // Get result
+ res := <-resultCh
+ if res.err != nil {
+ t.Fatalf("compactCondensed: %v", res.err)
+ }
+
+ // With the bug: returns non-nil summaryID even though context_items has no matching ordinals
+ // The fix: should return nil when startOrd == -1
+ if res.summaryID != nil {
+ t.Errorf("compactCondensed returned summaryID=%s, want nil (orphan created)", *res.summaryID)
+
+ // Verify the orphan exists in DB
+ summary, _ := s.GetSummary(context.Background(), *res.summaryID)
+ if summary != nil && summary.Kind == SummaryKindCondensed {
+ // Check it's NOT in context_items (orphan)
+ items2, _ := s.GetContextItems(context.Background(), convID)
+ found := false
+ for _, item := range items2 {
+ if item.SummaryID == *res.summaryID {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("condensed summary exists in DB but not in context_items — orphan confirmed")
+ }
+ }
+ }
+}
+
+func TestCompactUntilUnder(t *testing.T) {
+ ce, s, convID := newTestCompactionEngine(t)
+ ctx := context.Background()
+
+ // Create many leaf summaries to ensure we can condense
+ for i := 0; i < 8; i++ {
+ now := time.Now().UTC()
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "leaf summary for condensation test",
+ TokenCount: 500,
+ EarliestAt: &now,
+ LatestAt: &now,
+ })
+ s.AppendContextSummary(ctx, convID, summary.SummaryID)
+ }
+
+ // Force compact until under budget
+ result, err := ce.CompactUntilUnder(ctx, convID, 2000)
+ if err != nil {
+ t.Fatalf("CompactUntilUnder: %v", err)
+ }
+
+ if result == nil {
+ t.Fatal("expected non-nil result")
+ }
+}
+
+func TestSelectShallowestCondensationCandidate(t *testing.T) {
+ ce, s, convID := newTestCompactionEngine(t)
+ ctx := context.Background()
+
+ // Create enough leaf summaries + fresh messages for candidates
+ for i := 0; i < LeafMinFanout; i++ {
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "leaf",
+ TokenCount: 100,
+ })
+ s.AppendContextSummary(ctx, convID, summary.SummaryID)
+ }
+
+ // Add fresh tail messages so summaries are in evictable range
+ for i := 0; i < FreshTailCount+1; i++ {
+ m, _ := s.AddMessage(ctx, convID, "user", "fresh", 5)
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+
+ candidates, err := ce.selectShallowestCondensationCandidate(ctx, convID, false)
+ if err != nil {
+ t.Fatalf("selectShallowestCondensationCandidate: %v", err)
+ }
+
+ // Should find leaf summaries at depth 0
+ if len(candidates) < CondensedMinFanout {
+ t.Errorf("candidates = %d, want >= %d", len(candidates), CondensedMinFanout)
+ }
+}
+
+func TestSelectShallowestCondensationCandidateEmpty(t *testing.T) {
+ ce, _, convID := newTestCompactionEngine(t)
+ ctx := context.Background()
+
+ candidates, err := ce.selectShallowestCondensationCandidate(ctx, convID, false)
+ if err != nil {
+ t.Fatalf("selectShallowestCondensationCandidate: %v", err)
+ }
+ if len(candidates) != 0 {
+ t.Errorf("candidates = %d, want 0 for empty context", len(candidates))
+ }
+}
+
+func TestCompactCondensedUsesSelectOldestChunk(t *testing.T) {
+ // Verify that compactCondensed prefers ordinal-ordered chunks via selectOldestChunkAtDepth
+ // rather than just grouping by depth without regard to order
+ ce, s, convID := newTestCompactionEngine(t)
+ ctx := context.Background()
+
+ // Create interleaved summaries at depth 0 with a message in between:
+ // sum1 (ordinal 100), msg (ordinal 200), sum2 (ordinal 300)
+
+ for i := 0; i < LeafMinFanout+2; i++ {
+ now := time.Now().UTC()
+
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: fmt.Sprintf("leaf summary %d", i),
+ TokenCount: 100,
+ EarliestAt: &now,
+ LatestAt: &now,
+ })
+ }
+
+ // Insert a message between first two summaries to break contiguity
+ // for selectShallowestCondensationCandidate but would still find all 3
+ // but selectOldestChunkAtDepth should only find sum1 + sum2 (not sum3)
+
+ msg, _ := s.AddMessage(ctx, convID, "user", "interrupting message", 5)
+ s.AppendContextMessage(ctx, convID, msg.ID)
+
+ // Run compactCondensed
+ result, err := ce.compactCondensed(ctx, convID)
+ if err != nil {
+ t.Fatalf("compactCondensed: %v", err)
+ }
+
+ // The result should have merged the two summaries at the start
+ // (skipping the message in between), This proves ordinal-aware selection works.
+
+ _ = result // verify summary was created
+
+ if result != nil {
+ summaries, _ := s.GetSummariesByConversation(ctx, convID)
+ found := false
+ for _, sum := range summaries {
+ if sum.Kind == SummaryKindCondensed {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("expected condensed summary to be created via ordinal-aware selection")
+ }
+ }
+}
+
+func TestCompactCondensedUsesOrdinalAwareSelection(t *testing.T) {
+ ce, s, convID := newTestCompactionEngine(t)
+ ctx := context.Background()
+
+ // Create leaf summaries at depth 0 (total tokens >= CondensedTargetTokens)
+ for i := 0; i < 5; i++ {
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: fmt.Sprintf("leaf summary %d", i),
+ TokenCount: 500, // 5 × 500 = 2500 >= CondensedTargetTokens (2000)
+ })
+ s.AppendContextSummary(ctx, convID, summary.SummaryID)
+ }
+
+ // Add fresh tail
+ for i := 0; i < FreshTailCount+1; i++ {
+ m, _ := s.AddMessage(ctx, convID, "user", "fresh", 5)
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+
+ chunk, err := ce.selectOldestChunkAtDepth(ctx, convID, 0)
+ if err != nil {
+ t.Fatalf("selectOldestChunkAtDepth: %v", err)
+ }
+ if len(chunk) < 2 {
+ t.Errorf("chunk length = %d, want >= 2 contiguous summaries", len(chunk))
+ }
+ for _, s := range chunk {
+ if s.Depth != 0 {
+ t.Errorf("got depth %d, want 0", s.Depth)
+ }
+ }
+}
+
+func TestSelectOldestChunkAtDepthBreaksOnMessage(t *testing.T) {
+ ce, s, convID := newTestCompactionEngine(t)
+ ctx := context.Background()
+
+ // Create 3 summaries, then a message, then 3 more summaries
+ for i := 0; i < 3; i++ {
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: fmt.Sprintf("leaf %d", i),
+ TokenCount: 100,
+ })
+ s.AppendContextSummary(ctx, convID, summary.SummaryID)
+ }
+ msg, _ := s.AddMessage(ctx, convID, "user", "break", 10)
+ s.AppendContextMessage(ctx, convID, msg.ID)
+ for i := 0; i < 3; i++ {
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: fmt.Sprintf("leaf-after %d", i),
+ TokenCount: 100,
+ })
+ s.AppendContextSummary(ctx, convID, summary.SummaryID)
+ }
+ for i := 0; i < FreshTailCount+1; i++ {
+ m, _ := s.AddMessage(ctx, convID, "user", "fresh", 5)
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+
+ chunk, _ := ce.selectOldestChunkAtDepth(ctx, convID, 0)
+ if len(chunk) > 3 {
+ t.Errorf("chunk length = %d, want <= 3 (message breaks chain)", len(chunk))
+ }
+}
+
+func TestSelectOldestChunkAtDepthMinTokens(t *testing.T) {
+ ce, s, convID := newTestCompactionEngine(t)
+ ctx := context.Background()
+
+ // Create summaries with very low token counts (total < 2000)
+ for i := 0; i < 5; i++ {
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: fmt.Sprintf("tiny summary %d", i),
+ TokenCount: 50, // very small
+ })
+ s.AppendContextSummary(ctx, convID, summary.SummaryID)
+ }
+
+ // Add fresh tail to protect from compaction
+ for i := 0; i < FreshTailCount+1; i++ {
+ m, _ := s.AddMessage(ctx, convID, "user", fmt.Sprintf("tail %d", i), 10)
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+
+ // Should return nil because total tokens (250) < 2000 minimum
+ chunk, err := ce.selectOldestChunkAtDepth(ctx, convID, 0)
+ if err != nil {
+ t.Fatalf("selectOldestChunkAtDepth: %v", err)
+ }
+ if len(chunk) > 0 {
+ t.Errorf("expected empty chunk when tokens < 2000, got %d summaries", len(chunk))
+ }
+}
+
+func TestSelectOldestChunkAtDepthPassesMinTokens(t *testing.T) {
+ ce, s, convID := newTestCompactionEngine(t)
+ ctx := context.Background()
+
+ // Create summaries with enough tokens (total >= 2000)
+ for i := 0; i < 5; i++ {
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: fmt.Sprintf(
+ "substantial summary with enough content to meet minimum token threshold for condensation candidate %d",
+ i,
+ ),
+ TokenCount: 500, // 5 × 500 = 2500 >= 2000
+ })
+ s.AppendContextSummary(ctx, convID, summary.SummaryID)
+ }
+
+ // Add fresh tail
+ for i := 0; i < FreshTailCount+1; i++ {
+ m, _ := s.AddMessage(ctx, convID, "user", fmt.Sprintf("tail %d", i), 10)
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+
+ // Should return chunk because total tokens (2500) >= 2000
+ chunk, err := ce.selectOldestChunkAtDepth(ctx, convID, 0)
+ if err != nil {
+ t.Fatalf("selectOldestChunkAtDepth: %v", err)
+ }
+ if len(chunk) == 0 {
+ t.Error("expected non-empty chunk when tokens >= 2000")
+ }
+}
+
+func TestGenerateLeafSummary(t *testing.T) {
+ ce, _, _ := newTestCompactionEngine(t)
+ ctx := context.Background()
+
+ msgs := []Message{
+ {Role: "user", Content: "hello world", TokenCount: 5},
+ {Role: "assistant", Content: "hi there", TokenCount: 5},
+ }
+
+ content, err := ce.generateLeafSummary(ctx, msgs, "")
+ if err != nil {
+ t.Fatalf("generateLeafSummary: %v", err)
+ }
+ if content == "" {
+ t.Error("expected non-empty summary content")
+ }
+}
+
+func TestGenerateLeafSummaryEscalationToAggressive(t *testing.T) {
+ // Level 1 returns summary that's too large (tokens >= input), should escalate to level 2
+ var calls []string
+ escalateComplete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) {
+ if contains(prompt, "Aggressive summary policy") {
+ calls = append(calls, "aggressive")
+ return "Short aggressive summary.", nil
+ }
+ calls = append(calls, "normal")
+ // Return a very long summary to trigger escalation
+ longContent := make([]byte, 5000)
+ for i := range longContent {
+ longContent[i] = 'x'
+ }
+ return string(longContent), nil
+ }
+
+ s := openTestStore(t)
+ ce, _ := newTestCompactionEngineWithStore(s, escalateComplete)
+
+ msgs := []Message{
+ {Role: "user", Content: "hello world", TokenCount: 10},
+ {Role: "assistant", Content: "response", TokenCount: 10},
+ }
+
+ content, err := ce.generateLeafSummary(context.Background(), msgs, "")
+ if err != nil {
+ t.Fatalf("generateLeafSummary: %v", err)
+ }
+ if content == "" {
+ t.Error("expected non-empty summary content")
+ }
+ // Should have called both normal and aggressive
+ foundNormal := false
+ foundAggressive := false
+ for _, c := range calls {
+ if c == "normal" {
+ foundNormal = true
+ }
+ if c == "aggressive" {
+ foundAggressive = true
+ }
+ }
+ if !foundNormal {
+ t.Error("expected normal LLM call")
+ }
+ if !foundAggressive {
+ t.Error("expected aggressive LLM call (level 2 escalation)")
+ }
+}
+
+func TestGenerateLeafSummaryEscalatesWhenLevel1MissesTarget(t *testing.T) {
+ var calls []string
+ normalContent := strings.Repeat("n", 1000) // ~404 tokens: below input, above target
+ aggressiveContent := strings.Repeat("a", 450) // ~184 tokens: within aggressive target
+ escalateComplete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) {
+ if contains(prompt, "Aggressive summary policy") {
+ calls = append(calls, "aggressive")
+ return aggressiveContent, nil
+ }
+ calls = append(calls, "normal")
+ return normalContent, nil
+ }
+
+ s := openTestStore(t)
+ ce, _ := newTestCompactionEngineWithStore(s, escalateComplete)
+
+ msgs := []Message{
+ {Role: "user", Content: "hello world", TokenCount: 500},
+ {Role: "assistant", Content: "response", TokenCount: 500},
+ }
+
+ content, err := ce.generateLeafSummary(context.Background(), msgs, "")
+ if err != nil {
+ t.Fatalf("generateLeafSummary: %v", err)
+ }
+ if content != aggressiveContent {
+ t.Fatalf("expected aggressive summary after level 1 missed target")
+ }
+ if len(calls) != 2 || calls[0] != "normal" || calls[1] != "aggressive" {
+ t.Fatalf("expected normal then aggressive calls, got %v", calls)
+ }
+}
+
+func TestGenerateLeafSummaryAcceptsContentAtTargetBoundary(t *testing.T) {
+ exactTargetContent := strings.Repeat("x", 488) // (488 + 12) * 2 / 5 = 200 tokens
+ var aggressiveCalled bool
+ complete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) {
+ if contains(prompt, "Aggressive summary policy") {
+ aggressiveCalled = true
+ }
+ return exactTargetContent, nil
+ }
+
+ s := openTestStore(t)
+ ce, _ := newTestCompactionEngineWithStore(s, complete)
+
+ msgs := []Message{
+ {Role: "user", Content: "hello world", TokenCount: 286},
+ {Role: "assistant", Content: "response", TokenCount: 286},
+ }
+
+ content, err := ce.generateLeafSummary(context.Background(), msgs, "")
+ if err != nil {
+ t.Fatalf("generateLeafSummary: %v", err)
+ }
+ if content != exactTargetContent {
+ t.Fatalf("expected level 1 summary at target boundary to be accepted")
+ }
+ if aggressiveCalled {
+ t.Fatal("did not expect aggressive retry when level 1 hit target exactly")
+ }
+}
+
+func TestGenerateLeafSummaryEscalationToTruncation(t *testing.T) {
+ // Both normal and aggressive return empty, should escalate to level 3 truncation
+ emptyComplete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) {
+ return "", nil
+ }
+
+ s := openTestStore(t)
+ ce, _ := newTestCompactionEngineWithStore(s, emptyComplete)
+
+ msgs := []Message{
+ {Role: "user", Content: "hello world from test", TokenCount: 10},
+ {Role: "assistant", Content: "response text here", TokenCount: 10},
+ }
+
+ content, err := ce.generateLeafSummary(context.Background(), msgs, "")
+ if err != nil {
+ t.Fatalf("generateLeafSummary: %v", err)
+ }
+ // Level 3 truncation should have produced something
+ if content == "" {
+ t.Error("expected non-empty content from level 3 truncation fallback")
+ }
+ if !contains(content, "Truncated from") {
+ t.Errorf("expected truncation marker in content: %q", content)
+ }
+}
+
+func TestGenerateCondensedSummary(t *testing.T) {
+ ce, _, _ := newTestCompactionEngine(t)
+ ctx := context.Background()
+
+ summaries := []Summary{
+ {SummaryID: "sum_a", Content: "first summary", TokenCount: 100},
+ {SummaryID: "sum_b", Content: "second summary", TokenCount: 100},
+ }
+
+ content, err := ce.generateCondensedSummary(ctx, summaries)
+ if err != nil {
+ t.Fatalf("generateCondensedSummary: %v", err)
+ }
+ if content == "" {
+ t.Error("expected non-empty condensed summary content")
+ }
+}
+
+func TestGenerateCondensedSummaryEscalation(t *testing.T) {
+ // When LLM returns empty, should fall back to deterministic concatenation
+ emptyComplete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) {
+ return "", nil
+ }
+
+ s := openTestStore(t)
+ ce, _ := newTestCompactionEngineWithStore(s, emptyComplete)
+
+ summaries := []Summary{
+ {SummaryID: "sum_a", Content: "first summary text", TokenCount: 50},
+ {SummaryID: "sum_b", Content: "second summary text", TokenCount: 50},
+ }
+
+ content, err := ce.generateCondensedSummary(context.Background(), summaries)
+ if err != nil {
+ t.Fatalf("generateCondensedSummary: %v", err)
+ }
+ // Should fall back to concatenation
+ if content == "" {
+ t.Error("expected non-empty content from fallback")
+ }
+}
+
+// --- Async Condensed Compaction (Phase 2) ---
+
+func TestCompactAsyncReturnsBeforeCondensed(t *testing.T) {
+ // Use a slow CompleteFn to verify Compact returns before condensed finishes
+ var callCount int32
+ slowComplete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) {
+ atomic.AddInt32(&callCount, 1)
+ time.Sleep(500 * time.Millisecond) // simulate slow LLM
+ return "Slow condensed summary.", nil
+ }
+
+ s := openTestStore(t)
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "test:async")
+ convID := conv.ConversationID
+
+ ce, cancel := newTestCompactionEngineWithStore(s, slowComplete)
+ t.Cleanup(func() {
+ cancel()
+ time.Sleep(100 * time.Millisecond)
+ })
+
+ // Create enough leaf summaries for condensation + fresh tail
+ for i := 0; i < CondensedMinFanout; i++ {
+ now := time.Now().UTC()
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "leaf for async test",
+ TokenCount: 500,
+ EarliestAt: &now,
+ LatestAt: &now,
+ })
+ s.AppendContextSummary(ctx, convID, summary.SummaryID)
+ }
+ for i := 0; i < FreshTailCount; i++ {
+ m, _ := s.AddMessage(ctx, convID, "user", "fresh", 10)
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+
+ // Compact with force — should return quickly, condensed runs async
+ start := time.Now()
+ result, err := ce.Compact(ctx, convID, CompactInput{Force: true})
+ elapsed := time.Since(start)
+
+ if err != nil {
+ t.Fatalf("Compact: %v", err)
+ }
+ if result == nil {
+ t.Fatal("expected non-nil result")
+ }
+
+ // Should return well before the 500ms LLM call
+ if elapsed > 200*time.Millisecond {
+ t.Errorf("Compact took %v, should return before async condensed finishes", elapsed)
+ }
+
+ // Wait for async to complete
+ time.Sleep(800 * time.Millisecond)
+
+ // Verify condensed summary was created by background goroutine
+ summaries, _ := s.GetSummariesByConversation(ctx, convID)
+ foundCondensed := false
+ for _, sum := range summaries {
+ if sum.Kind == SummaryKindCondensed {
+ foundCondensed = true
+ break
+ }
+ }
+ if !foundCondensed {
+ t.Error("expected at least one condensed summary from async Phase 2")
+ }
+}
+
+func TestCompactAsyncDedup(t *testing.T) {
+ var callCount int32
+ slowComplete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) {
+ atomic.AddInt32(&callCount, 1)
+ time.Sleep(300 * time.Millisecond)
+ return "Slow condensed summary.", nil
+ }
+
+ s := openTestStore(t)
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "test:dedup")
+ convID := conv.ConversationID
+
+ ce, cancel := newTestCompactionEngineWithStore(s, slowComplete)
+ t.Cleanup(func() {
+ cancel()
+ waitForCondensed(ce, convID, 2*time.Second)
+ })
+
+ // Create conditions for condensed compaction
+ for i := 0; i < CondensedMinFanout; i++ {
+ now := time.Now().UTC()
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "leaf for dedup",
+ TokenCount: 500,
+ EarliestAt: &now,
+ LatestAt: &now,
+ })
+ s.AppendContextSummary(ctx, convID, summary.SummaryID)
+ }
+ for i := 0; i < FreshTailCount; i++ {
+ m, _ := s.AddMessage(ctx, convID, "user", "fresh", 10)
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+
+ // Call Compact twice rapidly
+ ce.Compact(ctx, convID, CompactInput{Force: true})
+ ce.Compact(ctx, convID, CompactInput{Force: true})
+
+ // Wait for async to finish
+ time.Sleep(600 * time.Millisecond)
+
+ // LLM should only be called once for condensed (dedup)
+ // callCount may be 0 if no leaf was created (only condensed in goroutine)
+ // The key is that we don't get 2+ condensed calls
+ if atomic.LoadInt32(&callCount) > 1 {
+ t.Errorf("LLM called %d times, expected at most 1 (dedup)", callCount)
+ }
+}
+
+func TestCompactLeafForceBypassesFreshTail(t *testing.T) {
+ // Spec: compactLeaf with force=true should bypass FreshTailCount protection
+ // so CompactUntilUnder can compress messages inside the fresh tail
+ ce, s, convID := newTestCompactionEngine(t)
+ ctx := context.Background()
+
+ // Create exactly FreshTailCount+4 messages (36 total)
+ // Without force: all messages are in fresh tail → no candidate
+ // With force: should compact the oldest messages
+ total := FreshTailCount + 4
+ for i := 0; i < total; i++ {
+ m, _ := s.AddMessage(ctx, convID, "user", fmt.Sprintf("message %d for force test", i), 100)
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+
+ // Without force: should return nil (all in fresh tail)
+ summaryID, err := ce.compactLeaf(ctx, convID)
+ if err != nil {
+ t.Fatalf("compactLeaf no-force: %v", err)
+ }
+ if summaryID != nil {
+ t.Error("expected nil without force (all messages in fresh tail)")
+ }
+
+ // With force: should compact despite fresh tail protection
+ summaryID, err = ce.compactLeaf(ctx, convID, true)
+ if err != nil {
+ t.Fatalf("compactLeaf force: %v", err)
+ }
+ if summaryID == nil {
+ t.Error("expected summary with force=true (bypasses fresh tail)")
+ }
+}
+
+func TestCompactLeafAccumulatesUpToLeafChunkTokens(t *testing.T) {
+ // Spec: compactLeaf should accumulate messages up to LeafChunkTokens before stopping
+ // It should NOT take the entire contiguous chunk regardless of token count
+ ce, s, convID := newTestCompactionEngine(t)
+ ctx := context.Background()
+
+ // Create messages totaling far more than LeafChunkTokens (20000)
+ // Each message is ~500 tokens, create 80 messages = 40000 tokens
+ for i := 0; i < 80; i++ {
+ m, _ := s.AddMessage(
+ ctx,
+ convID,
+ "user",
+ fmt.Sprintf(
+ "message %d with lots of content to make it big enough for token counting purposes and this should be a substantial message body that represents a meaningful conversation turn",
+ i,
+ ),
+ 500,
+ )
+ s.AppendContextMessage(ctx, convID, m.ID)
+ }
+
+ summaryID, err := ce.compactLeaf(ctx, convID)
+ if err != nil {
+ t.Fatalf("compactLeaf: %v", err)
+ }
+ if summaryID == nil {
+ t.Fatal("expected a summary to be created")
+ }
+
+ // The source messages that were compacted should total roughly LeafChunkTokens (20000),
+ // not the entire 40000 tokens worth of messages
+ summary, _ := s.GetSummary(ctx, *summaryID)
+ if summary == nil {
+ t.Fatal("summary not found")
+ }
+
+ // Source message tokens should be roughly <= LeafChunkTokens (20000)
+ // Spec says: "Stop when accumulated tokens >= LeafChunkTokens"
+ if summary.SourceMessageTokenCount > LeafChunkTokens {
+ t.Errorf("source tokens = %d, should be <= LeafChunkTokens (%d)",
+ summary.SourceMessageTokenCount, LeafChunkTokens)
+ }
+}
diff --git a/pkg/seahorse/short_constants.go b/pkg/seahorse/short_constants.go
new file mode 100644
index 000000000..943d7931e
--- /dev/null
+++ b/pkg/seahorse/short_constants.go
@@ -0,0 +1,30 @@
+package seahorse
+
+// Short-term memory configuration constants — all are experience-based defaults.
+
+const (
+ // OrdinalStep is the gap between ordinals in context_items.
+ // Insert at midpoint; resequence only when precision exhausted.
+ OrdinalStep = 100
+
+ // ContextThreshold is the compaction trigger for the context window.
+ ContextThreshold float64 = 0.75 // Compact at 75% of context window
+ FreshTailCount int = 32 // Recent messages protected from compaction
+
+ // LeafMinFanout is the fanout parameter.
+ LeafMinFanout int = 8 // Min messages per leaf summary
+ CondensedMinFanout int = 4 // Min summaries per condensed
+ CondensedMinFanoutHard int = 2 // Min for forced compaction
+
+ // LeafChunkTokens is the token target.
+ LeafChunkTokens int = 20000 // Max tokens per leaf chunk
+ LeafTargetTokens int = 1200 // Target tokens for leaf summaries
+ CondensedTargetTokens int = 2000 // Target tokens for condensed summaries
+ MaxExpandTokens int = 4000 // Token cap for expansion queries
+
+ // MaxCompactIterations caps CompactUntilUnder to prevent infinite loops.
+ // Each iteration reduces ~4x tokens via leaf (8:1) or condensed (4:1) compaction.
+ // With a 200k token context window and 75% threshold, ~20 iterations is enough
+ // for any realistic scenario. If exceeded, the issue is logged as a warning.
+ MaxCompactIterations int = 20
+)
diff --git a/pkg/seahorse/short_engine.go b/pkg/seahorse/short_engine.go
new file mode 100644
index 000000000..0a8175617
--- /dev/null
+++ b/pkg/seahorse/short_engine.go
@@ -0,0 +1,660 @@
+package seahorse
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+
+ _ "modernc.org/sqlite"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+// Config holds engine configuration.
+type Config struct {
+ DBPath string `json:"dbPath"`
+ IgnoreSessionPatterns []string `json:"ignoreSessionPatterns,omitempty"`
+ StatelessSessionPatterns []string `json:"statelessSessionPatterns,omitempty"`
+}
+
+// CompleteFn is the LLM completion function type.
+type CompleteFn func(ctx context.Context, prompt string, opts CompleteOptions) (string, error)
+
+// CompleteOptions holds LLM completion parameters.
+type CompleteOptions struct {
+ Model string
+ MaxTokens int
+ Temperature float64
+}
+
+// IngestResult is the result of message ingestion.
+type IngestResult struct {
+ MessageCount int `json:"messageCount"`
+ TokenCount int `json:"tokenCount"`
+}
+
+// AssembleInput controls context assembly.
+type AssembleInput struct {
+ Budget int `json:"budget"`
+ Query string `json:"query,omitempty"`
+}
+
+// AssembleResult contains assembled context.
+type AssembleResult struct {
+ Messages []Message `json:"messages"`
+ Summary string `json:"summary"` // formatted XML summaries + system prompt addition
+}
+
+const numSessionShards = 256
+
+// Engine is the main short-term memory engine.
+type Engine struct {
+ store *Store
+ compaction *CompactionEngine
+ compactionMu sync.Mutex
+ assembler *Assembler
+ assemblerMu sync.Mutex
+ retrieval *RetrievalEngine
+ config Config
+ complete CompleteFn
+ ignorePatterns []*regexp.Regexp
+ statelessPatterns []*regexp.Regexp
+ sessionShards [numSessionShards]struct {
+ mu sync.Mutex
+ }
+}
+
+// CompactionEngine handles LLM-based summarization (defined in short_compaction.go).
+type CompactionEngine struct {
+ store *Store
+ config Config
+ complete CompleteFn
+ condensing sync.Map // map[int64]struct{} — dedup for async condensed goroutines
+ shutdownCtx context.Context
+ shutdownCancel context.CancelFunc
+}
+
+// Assembler handles budget-aware context assembly (defined in short_assembler.go).
+type Assembler struct {
+ store *Store
+ config Config
+}
+
+// RetrievalEngine handles search and expansion (defined in short_retrieval.go).
+type RetrievalEngine struct {
+ store *Store
+ config Config
+}
+
+// Store returns the underlying store for direct access.
+func (r *RetrievalEngine) Store() *Store {
+ return r.store
+}
+
+// NewEngine creates a new short-term memory engine.
+func NewEngine(config Config, completeFn CompleteFn) (*Engine, error) {
+ dir := filepath.Dir(config.DBPath)
+ if dir != "" && dir != "." {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return nil, fmt.Errorf("create db directory: %w", err)
+ }
+ }
+
+ db, err := sql.Open("sqlite", config.DBPath)
+ if err != nil {
+ return nil, fmt.Errorf("open db: %w", err)
+ }
+
+ // Configure SQLite for concurrent access
+ if _, err := db.Exec("PRAGMA journal_mode = WAL;"); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("enable WAL: %w", err)
+ }
+ if _, err := db.Exec("PRAGMA busy_timeout = 5000;"); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("set busy_timeout: %w", err)
+ }
+ if _, err := db.Exec("PRAGMA synchronous = NORMAL;"); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("set synchronous: %w", err)
+ }
+
+ if err := runSchema(db); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("migrations: %w", err)
+ }
+
+ store := &Store{db: db}
+
+ // Prepend hardcoded ignore patterns (spec lines 1326-1328)
+ ignorePatterns := make([]string, 0, 1+len(config.IgnoreSessionPatterns))
+ ignorePatterns = append(ignorePatterns, "heartbeat")
+ ignorePatterns = append(ignorePatterns, config.IgnoreSessionPatterns...)
+
+ retrieval := &RetrievalEngine{store: store, config: config}
+
+ return &Engine{
+ store: store,
+ compaction: nil,
+ assembler: nil,
+ retrieval: retrieval,
+ config: config,
+ complete: completeFn,
+ ignorePatterns: compileSessionPatterns(ignorePatterns),
+ statelessPatterns: compileSessionPatterns(config.StatelessSessionPatterns),
+ }, nil
+}
+
+// compileSessionPattern converts a glob pattern to a compiled regex.
+// Pattern rules:
+// - * matches any sequence of non-colon characters ([^:]*)
+// - ** matches any sequence of characters including colons (.*)
+// - All other characters are treated literally
+// - Pattern is anchored (^...$)
+func compileSessionPattern(pattern string) *regexp.Regexp {
+ var b strings.Builder
+ b.WriteByte('^')
+
+ i := 0
+ for i < len(pattern) {
+ if i+1 < len(pattern) && pattern[i] == '*' && pattern[i+1] == '*' {
+ b.WriteString(".*")
+ i += 2
+ continue
+ }
+ if pattern[i] == '*' {
+ b.WriteString("[^:]*")
+ i++
+ continue
+ }
+ b.WriteString(regexp.QuoteMeta(string(pattern[i])))
+ i++
+ }
+
+ b.WriteByte('$')
+ return regexp.MustCompile(b.String())
+}
+
+// compileSessionPatterns compiles multiple glob patterns into regex patterns.
+func compileSessionPatterns(patterns []string) []*regexp.Regexp {
+ result := make([]*regexp.Regexp, 0, len(patterns))
+ for _, p := range patterns {
+ if p == "" {
+ continue
+ }
+ result = append(result, compileSessionPattern(p))
+ }
+ return result
+}
+
+// shouldIgnoreSession returns true if the session key matches any ignore pattern.
+func (e *Engine) shouldIgnoreSession(sessionKey string) bool {
+ for _, p := range e.ignorePatterns {
+ if p.MatchString(sessionKey) {
+ return true
+ }
+ }
+ return false
+}
+
+// isStatelessSession returns true if the session key matches any stateless pattern.
+func (e *Engine) isStatelessSession(sessionKey string) bool {
+ for _, p := range e.statelessPatterns {
+ if p.MatchString(sessionKey) {
+ return true
+ }
+ }
+ return false
+}
+
+// fnv32 computes FNV-1a 32-bit hash for session key sharding.
+func fnv32(key string) uint32 {
+ h := uint32(2166136261)
+ for _, c := range key {
+ h ^= uint32(c)
+ h *= 16777619
+ }
+ return h
+}
+
+// getSessionMutex returns the sharded mutex for a session key.
+func (e *Engine) getSessionMutex(sessionKey string) *sync.Mutex {
+ h := fnv32(sessionKey)
+ shard := h % numSessionShards
+ return &e.sessionShards[shard].mu
+}
+
+// Ingest adds messages to a conversation identified by sessionKey.
+func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Message) (*IngestResult, error) {
+ if e.shouldIgnoreSession(sessionKey) {
+ return nil, nil
+ }
+ if e.isStatelessSession(sessionKey) {
+ return nil, nil
+ }
+
+ mu := e.getSessionMutex(sessionKey)
+ mu.Lock()
+ defer mu.Unlock()
+
+ conv, err := e.store.GetOrCreateConversation(ctx, sessionKey)
+ if err != nil {
+ return nil, fmt.Errorf("get conversation: %w", err)
+ }
+
+ var totalTokens int
+ var msgIDs []int64
+ for _, msg := range messages {
+ var added *Message
+ var err error
+ if len(msg.Parts) > 0 {
+ added, err = e.store.AddMessageWithPartsAndReasoning(
+ ctx,
+ conv.ConversationID,
+ msg.Role,
+ msg.Parts,
+ msg.ReasoningContent,
+ msg.TokenCount,
+ )
+ } else {
+ added, err = e.store.AddMessageWithReasoning(
+ ctx,
+ conv.ConversationID,
+ msg.Role,
+ msg.Content,
+ msg.ReasoningContent,
+ msg.TokenCount,
+ )
+ }
+ if err != nil {
+ return nil, fmt.Errorf("add message: %w", err)
+ }
+ totalTokens += msg.TokenCount
+ msgIDs = append(msgIDs, added.ID)
+ }
+
+ // Append to context_items using actual inserted IDs
+ if err := e.store.AppendContextMessages(ctx, conv.ConversationID, msgIDs); err != nil {
+ return nil, fmt.Errorf("append context: %w", err)
+ }
+
+ logger.InfoCF("seahorse", "ingest", map[string]any{
+ "conv_id": conv.ConversationID,
+ "messages": len(messages),
+ "tokens": totalTokens,
+ })
+ return &IngestResult{
+ MessageCount: len(messages),
+ TokenCount: totalTokens,
+ }, nil
+}
+
+// Close releases resources.
+func (e *Engine) Close() error {
+ // Signal compaction goroutines to stop
+ if e.compaction != nil {
+ e.compaction.Close()
+ }
+ if e.store != nil && e.store.db != nil {
+ return e.store.db.Close()
+ }
+ return nil
+}
+
+// GetRetrieval returns the retrieval engine for tool implementations.
+func (e *Engine) GetRetrieval() *RetrievalEngine {
+ return e.retrieval
+}
+
+// Assemble builds budget-constrained context for a session.
+func (e *Engine) Assemble(ctx context.Context, sessionKey string, input AssembleInput) (*AssembleResult, error) {
+ if e.shouldIgnoreSession(sessionKey) {
+ return nil, nil
+ }
+
+ conv, err := e.store.GetOrCreateConversation(ctx, sessionKey)
+ if err != nil {
+ return nil, fmt.Errorf("get conversation: %w", err)
+ }
+
+ e.initAssemblerOnce()
+ return e.assembler.Assemble(ctx, conv.ConversationID, input)
+}
+
+// Compact compresses conversation history for a session.
+func (e *Engine) Compact(ctx context.Context, sessionKey string, input CompactInput) (*CompactResult, error) {
+ if e.shouldIgnoreSession(sessionKey) || e.isStatelessSession(sessionKey) {
+ return &CompactResult{}, nil
+ }
+
+ conv, err := e.store.GetOrCreateConversation(ctx, sessionKey)
+ if err != nil {
+ return nil, fmt.Errorf("get conversation: %w", err)
+ }
+
+ e.initCompactionOnce()
+ return e.compaction.Compact(ctx, conv.ConversationID, input)
+}
+
+// CompactUntilUnder aggressively compacts until context is under budget.
+// Used for emergency compaction after LLM overflow (retry reason).
+func (e *Engine) CompactUntilUnder(ctx context.Context, sessionKey string, budget int) (*CompactResult, error) {
+ if e.shouldIgnoreSession(sessionKey) || e.isStatelessSession(sessionKey) {
+ return &CompactResult{}, nil
+ }
+
+ conv, err := e.store.GetOrCreateConversation(ctx, sessionKey)
+ if err != nil {
+ return nil, fmt.Errorf("get conversation: %w", err)
+ }
+
+ e.initCompactionOnce()
+ return e.compaction.CompactUntilUnder(ctx, conv.ConversationID, budget)
+}
+
+// initCompactionOnce lazily initializes the compaction engine.
+func (e *Engine) initCompactionOnce() {
+ if e.compaction == nil {
+ e.compactionMu.Lock()
+ defer e.compactionMu.Unlock()
+ if e.compaction == nil {
+ shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
+ e.compaction = &CompactionEngine{
+ store: e.store,
+ config: e.config,
+ complete: e.complete,
+ shutdownCtx: shutdownCtx,
+ shutdownCancel: shutdownCancel,
+ }
+ }
+ }
+}
+
+// initAssemblerOnce lazily initializes the assembler.
+func (e *Engine) initAssemblerOnce() {
+ if e.assembler == nil {
+ e.assemblerMu.Lock()
+ defer e.assemblerMu.Unlock()
+ if e.assembler == nil {
+ e.assembler = &Assembler{store: e.store, config: e.config}
+ }
+ }
+}
+
+// IngestMessages is an alias for Ingest.
+func (e *Engine) IngestMessages(ctx context.Context, sessionKey string, messages []Message) (*IngestResult, error) {
+ return e.Ingest(ctx, sessionKey, messages)
+}
+
+// ClearSession removes all stored data for a session (messages, summaries, context).
+// If the session has no prior seahorse record, it is a no-op.
+func (e *Engine) ClearSession(ctx context.Context, sessionKey string) error {
+ conv, err := e.store.GetConversationBySessionKey(ctx, sessionKey)
+ if err != nil {
+ return err
+ }
+ if conv == nil {
+ return nil // session never ingested, nothing to clear
+ }
+ return e.store.ClearConversation(ctx, conv.ConversationID)
+}
+
+// Bootstrap reconciles a session's messages with the database.
+// Called once at startup for each known session.
+// Bootstrap reconciles JSONL history with SQLite by ingesting only the delta.
+// Simple approach: find longest matching prefix and append delta.
+// If any mismatch is detected, clear and rebuild.
+func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Message) error {
+ if e.shouldIgnoreSession(sessionKey) {
+ return nil
+ }
+ if e.isStatelessSession(sessionKey) {
+ return nil
+ }
+ if len(messages) == 0 {
+ return nil
+ }
+
+ conv, err := e.store.GetOrCreateConversation(ctx, sessionKey)
+ if err != nil {
+ return fmt.Errorf("bootstrap: get conversation: %w", err)
+ }
+
+ // Get messages already in DB
+ dbMsgs, err := e.store.GetMessages(ctx, conv.ConversationID, len(messages), 0)
+ if err != nil {
+ return fmt.Errorf("bootstrap: get messages: %w", err)
+ }
+
+ // Fast path: DB has same count and exact match → no-op
+ if len(dbMsgs) == len(messages) {
+ matched := true
+ for i := range messages {
+ if !messageMatches(dbMsgs[i], messages[i]) {
+ matched = false
+ break
+ }
+ }
+ if matched {
+ return nil // DB is up to date
+ }
+ }
+
+ // Migration repair path: old SeaHorse rows may be missing reasoning_content
+ // even though the canonical JSONL history already has it. Backfill those
+ // rows in place so we do not treat this as edited history and leave stale
+ // summaries/context behind after a partial raw-message rebuild.
+ if repaired, err := e.repairBootstrapReasoningContent(ctx, dbMsgs, messages); err != nil {
+ return fmt.Errorf("bootstrap: repair reasoning_content: %w", err)
+ } else if repaired && len(dbMsgs) == len(messages) {
+ return nil
+ }
+
+ // Find longest matching prefix from the start
+ anchor := -1
+ compareLen := min(len(dbMsgs), len(messages))
+
+ for i := range compareLen {
+ if messageMatches(dbMsgs[i], messages[i]) {
+ anchor = i
+ } else {
+ // Mismatch detected - log details and rebuild
+ logger.InfoCF("seahorse", "bootstrap: mismatch detected", map[string]any{
+ "conv_id": conv.ConversationID,
+ "index": i,
+ "db_role": dbMsgs[i].Role,
+ "db_content": truncate(dbMsgs[i].Content, 50),
+ "db_parts": len(dbMsgs[i].Parts),
+ "msg_role": messages[i].Role,
+ "msg_content": truncate(messages[i].Content, 50),
+ "msg_parts": len(messages[i].Parts),
+ })
+ break
+ }
+ }
+
+ // If we hit a mismatch before reaching the end of DB messages, delete delta and re-ingest
+ // Note: anchor can be -1 if first message didn't match (history completely changed)
+ if anchor >= 0 && anchor < len(dbMsgs)-1 && len(dbMsgs) > 0 {
+ anchorID := dbMsgs[anchor].ID
+ logger.InfoCF("seahorse", "bootstrap: history edit detected", map[string]any{
+ "conv_id": conv.ConversationID,
+ "db_count": len(dbMsgs),
+ "anchor": anchor,
+ "anchor_id": anchorID,
+ "msg_count": len(messages),
+ "delta_start": anchor + 1,
+ })
+
+ // Delete messages after anchor (also clears context_items)
+ if err := e.store.DeleteMessagesAfterID(ctx, conv.ConversationID, anchorID); err != nil {
+ return fmt.Errorf("bootstrap: delete messages: %w", err)
+ }
+
+ // Re-ingest from anchor+1 to end
+ delta := messages[anchor+1:]
+ if len(delta) > 0 {
+ _, err := e.Ingest(ctx, sessionKey, delta)
+ if err != nil {
+ return fmt.Errorf("bootstrap: re-ingest: %w", err)
+ }
+ }
+ return nil
+ }
+
+ // Normal case: append delta after anchor
+ if anchor >= 0 && anchor < len(messages)-1 {
+ delta := messages[anchor+1:]
+ if len(delta) > 0 {
+ _, err := e.Ingest(ctx, sessionKey, delta)
+ if err != nil {
+ return fmt.Errorf("bootstrap: ingest delta: %w", err)
+ }
+ }
+ } else if anchor == -1 && len(dbMsgs) > 0 {
+ // First message changed (history completely different) - rebuild from scratch
+ logger.InfoCF("seahorse", "bootstrap: history replaced, rebuilding", map[string]any{
+ "conv_id": conv.ConversationID,
+ "db_count": len(dbMsgs),
+ "msg_count": len(messages),
+ })
+ // Delete all existing messages
+ if err := e.store.DeleteMessagesAfterID(ctx, conv.ConversationID, 0); err != nil {
+ return fmt.Errorf("bootstrap: delete all messages: %w", err)
+ }
+ // Re-ingest everything
+ if len(messages) > 0 {
+ _, err := e.Ingest(ctx, sessionKey, messages)
+ if err != nil {
+ return fmt.Errorf("bootstrap: re-ingest all: %w", err)
+ }
+ }
+ } else if anchor == -1 && len(dbMsgs) == 0 {
+ // DB is empty, ingest everything
+ _, err := e.Ingest(ctx, sessionKey, messages)
+ if err != nil {
+ return fmt.Errorf("bootstrap: ingest all: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func (e *Engine) repairBootstrapReasoningContent(ctx context.Context, dbMsgs, messages []Message) (bool, error) {
+ if len(dbMsgs) == 0 || len(messages) == 0 {
+ return false, nil
+ }
+
+ overlap := min(len(messages), len(dbMsgs))
+
+ var updates []struct {
+ index int
+ messageID int64
+ reasoningContent string
+ }
+
+ for i := range overlap {
+ if !messageMatchesIgnoringReasoning(dbMsgs[i], messages[i]) {
+ return false, nil
+ }
+ if dbMsgs[i].ReasoningContent == messages[i].ReasoningContent {
+ continue
+ }
+ if dbMsgs[i].ReasoningContent != "" || messages[i].ReasoningContent == "" {
+ return false, nil
+ }
+ updates = append(updates, struct {
+ index int
+ messageID int64
+ reasoningContent string
+ }{
+ index: i,
+ messageID: dbMsgs[i].ID,
+ reasoningContent: messages[i].ReasoningContent,
+ })
+ }
+
+ if len(updates) == 0 {
+ return false, nil
+ }
+
+ for _, update := range updates {
+ if err := e.store.UpdateMessageReasoningContent(ctx, update.messageID, update.reasoningContent); err != nil {
+ return false, err
+ }
+ dbMsgs[update.index].ReasoningContent = update.reasoningContent
+ }
+
+ logger.InfoCF("seahorse", "bootstrap: repaired missing reasoning_content", map[string]any{
+ "messages": len(updates),
+ })
+ return true, nil
+}
+
+// truncate shortens a string for logging.
+func truncate(s string, maxLen int) string {
+ if len(s) <= maxLen {
+ return s
+ }
+ return s[:maxLen] + "..."
+}
+
+// messageMatches compares two messages using role + reasoning_content and then
+// either content or parts. TokenCount is NOT compared because it may be
+// re-estimated differently during bootstrap (e.g., via tokenizer.EstimateMessageTokens).
+// For messages with Parts (tool_use, tool_result), compare Parts instead of Content
+// because structured messages are matched by their parts payload.
+func messageMatches(a, b Message) bool {
+ if a.Role != b.Role || a.ReasoningContent != b.ReasoningContent {
+ return false
+ }
+ return messageMatchesIgnoringReasoning(a, b)
+}
+
+func messageMatchesIgnoringReasoning(a, b Message) bool {
+ if a.Role != b.Role {
+ return false
+ }
+ // If either message has Parts, compare Parts
+ if len(a.Parts) > 0 || len(b.Parts) > 0 {
+ return partsMatch(a.Parts, b.Parts)
+ }
+ // Simple text messages: compare Content
+ return a.Content == b.Content
+}
+
+// partsMatch compares two slices of MessagePart for equality.
+func partsMatch(a, b []MessagePart) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i].Type != b[i].Type {
+ return false
+ }
+ switch a[i].Type {
+ case "text":
+ if a[i].Text != b[i].Text {
+ return false
+ }
+ case "tool_use":
+ if a[i].Name != b[i].Name || a[i].Arguments != b[i].Arguments || a[i].ToolCallID != b[i].ToolCallID {
+ return false
+ }
+ case "tool_result":
+ if a[i].ToolCallID != b[i].ToolCallID || a[i].Text != b[i].Text {
+ return false
+ }
+ case "media":
+ if a[i].MediaURI != b[i].MediaURI || a[i].MimeType != b[i].MimeType {
+ return false
+ }
+ }
+ }
+ return true
+}
diff --git a/pkg/seahorse/short_engine_test.go b/pkg/seahorse/short_engine_test.go
new file mode 100644
index 000000000..2a5c6c5d8
--- /dev/null
+++ b/pkg/seahorse/short_engine_test.go
@@ -0,0 +1,1760 @@
+package seahorse
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+// helper: open a test engine with in-memory DB
+func newTestEngine(t *testing.T) *Engine {
+ t.Helper()
+ db := openTestDB(t)
+ if err := runSchema(db); err != nil {
+ t.Fatalf("migration: %v", err)
+ }
+ store := &Store{db: db}
+ return &Engine{
+ store: store,
+ config: Config{},
+ }
+}
+
+// --- compileSessionPattern ---
+
+func TestCompileSessionPattern(t *testing.T) {
+ tests := []struct {
+ pattern string
+ input string
+ want bool
+ }{
+ // Exact match
+ {"agent:abc123", "agent:abc123", true},
+ {"agent:abc123", "agent:def456", false},
+ // Single * — matches non-colon chars
+ {"agent:*", "agent:abc123", true},
+ {"agent:*", "agent:abc:def", false}, // * doesn't match colons
+ // ** — matches everything including colons
+ {"cron:**", "cron:backup", true},
+ {"cron:**", "cron:backup:daily", true},
+ {"cron:**", "agent:abc", false},
+ // Mixed
+ {"agent:*:sub:**", "agent:abc:sub:def", true},
+ {"agent:*:sub:**", "agent:abc:sub:def:ghi", true},
+ {"agent:*:sub:**", "agent:abc:def", false},
+ // Empty pattern — matches nothing meaningful
+ {"", "", true},
+ {"", "agent:abc", false},
+ }
+
+ for _, tt := range tests {
+ re := compileSessionPattern(tt.pattern)
+ if re == nil && tt.pattern != "" {
+ t.Fatalf("compileSessionPattern(%q) returned nil", tt.pattern)
+ }
+ if tt.pattern == "" {
+ continue
+ }
+ got := re.MatchString(tt.input)
+ if got != tt.want {
+ t.Errorf("compileSessionPattern(%q).Match(%q) = %v, want %v", tt.pattern, tt.input, got, tt.want)
+ }
+ }
+}
+
+// --- Session Pattern Filtering ---
+
+func TestEngineShouldIgnoreSession(t *testing.T) {
+ eng := &Engine{
+ ignorePatterns: compileSessionPatterns([]string{"cron:**", "test:*"}),
+ }
+
+ tests := []struct {
+ key string
+ want bool
+ }{
+ {"cron:backup", true},
+ {"cron:backup:daily", true},
+ {"test:session", true},
+ {"agent:abc", false},
+ {"", false},
+ }
+
+ for _, tt := range tests {
+ got := eng.shouldIgnoreSession(tt.key)
+ if got != tt.want {
+ t.Errorf("shouldIgnoreSession(%q) = %v, want %v", tt.key, got, tt.want)
+ }
+ }
+}
+
+func TestEngineIsStatelessSession(t *testing.T) {
+ eng := &Engine{
+ statelessPatterns: compileSessionPatterns([]string{"agent:*:sub:**"}),
+ }
+
+ tests := []struct {
+ key string
+ want bool
+ }{
+ {"agent:abc:sub:def", true},
+ {"agent:abc:sub:def:ghi", true},
+ {"agent:abc", false},
+ {"cron:backup", false},
+ }
+
+ for _, tt := range tests {
+ got := eng.isStatelessSession(tt.key)
+ if got != tt.want {
+ t.Errorf("isStatelessSession(%q) = %v, want %v", tt.key, got, tt.want)
+ }
+ }
+}
+
+// --- NewEngine ---
+
+func TestNewEngine(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "short.db")
+
+ eng, err := NewEngine(Config{DBPath: dbPath}, nil)
+ if err != nil {
+ t.Fatalf("NewEngine: %v", err)
+ }
+ defer eng.Close()
+
+ // DB file should exist
+ if _, pathErr := os.Stat(dbPath); os.IsNotExist(pathErr) {
+ t.Error("expected DB file to be created")
+ }
+
+ // Store should be usable
+ ctx := context.Background()
+ conv, err := eng.store.GetOrCreateConversation(ctx, "test:session")
+ if err != nil {
+ t.Fatalf("store should work: %v", err)
+ }
+ if conv.ConversationID == 0 {
+ t.Error("expected valid conversation ID")
+ }
+
+ // GetRetrieval should return non-nil RetrievalEngine
+ retrieval := eng.GetRetrieval()
+ if retrieval == nil {
+ t.Error("expected GetRetrieval to return non-nil RetrievalEngine")
+ }
+}
+
+func TestNewEngineWithPatterns(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "short.db")
+
+ eng, err := NewEngine(Config{
+ DBPath: dbPath,
+ IgnoreSessionPatterns: []string{"cron:**"},
+ StatelessSessionPatterns: []string{"agent:*:sub:**"},
+ }, nil)
+ if err != nil {
+ t.Fatalf("NewEngine: %v", err)
+ }
+ defer eng.Close()
+
+ if !eng.shouldIgnoreSession("cron:backup") {
+ t.Error("expected cron:backup to be ignored")
+ }
+ if !eng.isStatelessSession("agent:abc:sub:def") {
+ t.Error("expected agent:abc:sub:def to be stateless")
+ }
+}
+
+// --- Ingest ---
+
+func TestEngineIngest(t *testing.T) {
+ eng := newTestEngine(t)
+ ctx := context.Background()
+
+ msgs := []Message{
+ {Role: "user", Content: "hello", TokenCount: 2},
+ {Role: "assistant", Content: "world", TokenCount: 2},
+ }
+
+ result, err := eng.Ingest(ctx, "agent:test", msgs)
+ if err != nil {
+ t.Fatalf("Ingest: %v", err)
+ }
+ if result.MessageCount != 2 {
+ t.Errorf("MessageCount = %d, want 2", result.MessageCount)
+ }
+ if result.TokenCount != 4 {
+ t.Errorf("TokenCount = %d, want 4", result.TokenCount)
+ }
+
+ // Verify messages were stored
+ conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:test")
+ stored, _ := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if len(stored) != 2 {
+ t.Fatalf("stored messages = %d, want 2", len(stored))
+ }
+ if stored[0].Content != "hello" {
+ t.Errorf("stored[0].Content = %q, want 'hello'", stored[0].Content)
+ }
+
+ // Verify context_items were populated
+ items, _ := eng.store.GetContextItems(ctx, conv.ConversationID)
+ if len(items) != 2 {
+ t.Fatalf("context items = %d, want 2", len(items))
+ }
+ if items[0].ItemType != "message" {
+ t.Errorf("item[0].ItemType = %q, want 'message'", items[0].ItemType)
+ }
+}
+
+func TestEngineIngestIgnoresSession(t *testing.T) {
+ eng := newTestEngine(t)
+ eng.ignorePatterns = compileSessionPatterns([]string{"cron:**"})
+ ctx := context.Background()
+
+ msgs := []Message{{Role: "user", Content: "hello", TokenCount: 2}}
+ result, err := eng.Ingest(ctx, "cron:backup", msgs)
+ if err != nil {
+ t.Fatalf("Ingest: %v", err)
+ }
+ if result != nil {
+ t.Error("expected nil result for ignored session")
+ }
+
+ // Verify no data was stored
+ conv, _ := eng.store.GetConversationBySessionKey(ctx, "cron:backup")
+ if conv != nil {
+ t.Error("expected no conversation for ignored session")
+ }
+}
+
+func TestEngineIngestStatelessSession(t *testing.T) {
+ eng := newTestEngine(t)
+ eng.statelessPatterns = compileSessionPatterns([]string{"agent:*:ro"})
+ ctx := context.Background()
+
+ msgs := []Message{{Role: "user", Content: "hello", TokenCount: 2}}
+ result, err := eng.Ingest(ctx, "agent:abc:ro", msgs)
+ if err != nil {
+ t.Fatalf("Ingest: %v", err)
+ }
+ if result != nil {
+ t.Error("expected nil result for stateless session")
+ }
+}
+
+func TestEngineIngestIncremental(t *testing.T) {
+ eng := newTestEngine(t)
+ ctx := context.Background()
+
+ // First ingest
+ eng.Ingest(ctx, "agent:test", []Message{
+ {Role: "user", Content: "msg1", TokenCount: 1},
+ })
+ // Second ingest — should append, not replace
+ eng.Ingest(ctx, "agent:test", []Message{
+ {Role: "assistant", Content: "msg2", TokenCount: 1},
+ })
+
+ conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:test")
+ stored, _ := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if len(stored) != 2 {
+ t.Errorf("stored messages = %d, want 2", len(stored))
+ }
+}
+
+func TestEngineIngestWithParts(t *testing.T) {
+ eng := newTestEngine(t)
+ ctx := context.Background()
+
+ msgs := []Message{
+ {
+ Role: "assistant",
+ Content: "",
+ TokenCount: 10,
+ Parts: []MessagePart{
+ {Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"},
+ {Type: "text", Text: "here is the file content"},
+ },
+ },
+ }
+
+ result, err := eng.Ingest(ctx, "agent:parts-test", msgs)
+ if err != nil {
+ t.Fatalf("Ingest with parts: %v", err)
+ }
+ if result.MessageCount != 1 {
+ t.Errorf("MessageCount = %d, want 1", result.MessageCount)
+ }
+
+ // Verify message was stored WITH parts
+ conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:parts-test")
+ stored, _ := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if len(stored) != 1 {
+ t.Fatalf("stored messages = %d, want 1", len(stored))
+ }
+ if len(stored[0].Parts) != 2 {
+ t.Fatalf("stored message parts = %d, want 2", len(stored[0].Parts))
+ }
+ if stored[0].Parts[0].Type != "tool_use" {
+ t.Errorf("part[0].Type = %q, want tool_use", stored[0].Parts[0].Type)
+ }
+ if stored[0].Parts[0].Name != "read_file" {
+ t.Errorf("part[0].Name = %q, want read_file", stored[0].Parts[0].Name)
+ }
+ if stored[0].Parts[0].ToolCallID != "tc_123" {
+ t.Errorf("part[0].ToolCallID = %q, want tc_123", stored[0].Parts[0].ToolCallID)
+ }
+ if stored[0].Parts[1].Type != "text" {
+ t.Errorf("part[1].Type = %q, want text", stored[0].Parts[1].Type)
+ }
+ if stored[0].Parts[1].Text != "here is the file content" {
+ t.Errorf("part[1].Text = %q, want 'here is the file content'", stored[0].Parts[1].Text)
+ }
+}
+
+func TestEngineIngestPreservesReasoningContent(t *testing.T) {
+ eng := newTestEngine(t)
+ ctx := context.Background()
+
+ msgs := []Message{
+ {
+ Role: "assistant",
+ Content: "world",
+ ReasoningContent: "let me think this through",
+ TokenCount: 4,
+ },
+ }
+
+ _, err := eng.Ingest(ctx, "agent:reasoning", msgs)
+ if err != nil {
+ t.Fatalf("Ingest: %v", err)
+ }
+
+ conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:reasoning")
+ stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if err != nil {
+ t.Fatalf("GetMessages: %v", err)
+ }
+ if len(stored) != 1 {
+ t.Fatalf("stored messages = %d, want 1", len(stored))
+ }
+ if stored[0].ReasoningContent != "let me think this through" {
+ t.Errorf(
+ "stored[0].ReasoningContent = %q, want %q",
+ stored[0].ReasoningContent,
+ "let me think this through",
+ )
+ }
+
+ result, err := eng.Assemble(ctx, "agent:reasoning", AssembleInput{Budget: 1000})
+ if err != nil {
+ t.Fatalf("Assemble: %v", err)
+ }
+ if len(result.Messages) != 1 {
+ t.Fatalf("assembled messages = %d, want 1", len(result.Messages))
+ }
+ if result.Messages[0].ReasoningContent != "let me think this through" {
+ t.Errorf(
+ "assembled reasoning = %q, want %q",
+ result.Messages[0].ReasoningContent,
+ "let me think this through",
+ )
+ }
+}
+
+func TestEngineIngestWithPartsPreservesReasoningContent(t *testing.T) {
+ eng := newTestEngine(t)
+ ctx := context.Background()
+
+ msgs := []Message{
+ {
+ Role: "assistant",
+ ReasoningContent: "I need to inspect the file first",
+ TokenCount: 10,
+ Parts: []MessagePart{
+ {Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"},
+ },
+ },
+ }
+
+ _, err := eng.Ingest(ctx, "agent:parts-reasoning", msgs)
+ if err != nil {
+ t.Fatalf("Ingest: %v", err)
+ }
+
+ conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:parts-reasoning")
+ stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if err != nil {
+ t.Fatalf("GetMessages: %v", err)
+ }
+ if len(stored) != 1 {
+ t.Fatalf("stored messages = %d, want 1", len(stored))
+ }
+ if stored[0].ReasoningContent != "I need to inspect the file first" {
+ t.Errorf(
+ "stored reasoning = %q, want %q",
+ stored[0].ReasoningContent,
+ "I need to inspect the file first",
+ )
+ }
+
+ result, err := eng.Assemble(ctx, "agent:parts-reasoning", AssembleInput{Budget: 1000})
+ if err != nil {
+ t.Fatalf("Assemble: %v", err)
+ }
+ if len(result.Messages) != 1 {
+ t.Fatalf("assembled messages = %d, want 1", len(result.Messages))
+ }
+ if result.Messages[0].ReasoningContent != "I need to inspect the file first" {
+ t.Errorf(
+ "assembled reasoning = %q, want %q",
+ result.Messages[0].ReasoningContent,
+ "I need to inspect the file first",
+ )
+ }
+}
+
+func TestEngineIngestAssemblePreservesParts(t *testing.T) {
+ eng := newTestEngine(t)
+ ctx := context.Background()
+
+ // Ingest a message with tool_use parts
+ eng.Ingest(ctx, "agent:parts-roundtrip", []Message{
+ {Role: "user", Content: "list files", TokenCount: 3},
+ {
+ Role: "assistant",
+ Content: "",
+ TokenCount: 5,
+ Parts: []MessagePart{
+ {Type: "tool_use", Name: "bash", Arguments: `{"cmd":"ls"}`, ToolCallID: "tc_1"},
+ {Type: "text", Text: "found 3 files"},
+ },
+ },
+ })
+
+ // Assemble should return messages with parts intact
+ result, err := eng.Assemble(ctx, "agent:parts-roundtrip", AssembleInput{Budget: 1000})
+ if err != nil {
+ t.Fatalf("Assemble: %v", err)
+ }
+
+ if len(result.Messages) != 2 {
+ t.Fatalf("Assemble returned %d messages, want 2", len(result.Messages))
+ }
+
+ // The second message should have Parts populated
+ assistantMsg := result.Messages[1]
+ if len(assistantMsg.Parts) != 2 {
+ t.Fatalf("Assembled assistant message Parts = %d, want 2", len(assistantMsg.Parts))
+ }
+ if assistantMsg.Parts[0].Type != "tool_use" {
+ t.Errorf("part[0].Type = %q, want tool_use", assistantMsg.Parts[0].Type)
+ }
+ if assistantMsg.Parts[0].ToolCallID != "tc_1" {
+ t.Errorf("part[0].ToolCallID = %q, want tc_1", assistantMsg.Parts[0].ToolCallID)
+ }
+}
+
+// --- Session Mutex ---
+
+func TestEngineSessionMutex(t *testing.T) {
+ eng := newTestEngine(t)
+
+ mu1 := eng.getSessionMutex("agent:test")
+ mu2 := eng.getSessionMutex("agent:test")
+ mu3 := eng.getSessionMutex("agent:other")
+
+ if mu1 != mu2 {
+ t.Error("expected same mutex for same session key")
+ }
+ if mu1 == mu3 {
+ t.Error("expected different mutex for different session key")
+ }
+}
+
+// --- Close ---
+
+func TestEngineClose(t *testing.T) {
+ eng := newTestEngine(t)
+ if err := eng.Close(); err != nil {
+ t.Errorf("Close: %v", err)
+ }
+}
+
+// --- compileSessionPatterns (batch) ---
+
+func TestCompileSessionPatterns(t *testing.T) {
+ patterns := compileSessionPatterns([]string{"cron:**", "agent:*:ro"})
+ if len(patterns) != 2 {
+ t.Fatalf("expected 2 patterns, got %d", len(patterns))
+ }
+
+ tests := []struct {
+ input string
+ want bool
+ }{
+ {"cron:backup", true},
+ {"agent:abc:ro", true},
+ {"agent:abc:def", false},
+ {"", false},
+ }
+
+ for _, tt := range tests {
+ matched := false
+ for _, p := range patterns {
+ if p.MatchString(tt.input) {
+ matched = true
+ break
+ }
+ }
+ if matched != tt.want {
+ t.Errorf("patterns.Match(%q) = %v, want %v", tt.input, matched, tt.want)
+ }
+ }
+}
+
+func TestCompileSessionPatternsEmpty(t *testing.T) {
+ patterns := compileSessionPatterns(nil)
+ if len(patterns) != 0 {
+ t.Errorf("expected 0 patterns for nil input, got %d", len(patterns))
+ }
+}
+
+// --- Bootstrap ---
+
+func TestEngineBootstrap(t *testing.T) {
+ eng := newTestEngine(t)
+ ctx := context.Background()
+
+ msgs := []Message{
+ {Role: "user", Content: "hello", TokenCount: 3},
+ {Role: "assistant", Content: "world", TokenCount: 3},
+ {Role: "user", Content: "how are you", TokenCount: 5},
+ }
+
+ err := eng.Bootstrap(ctx, "agent:boot1", msgs)
+ if err != nil {
+ t.Fatalf("Bootstrap: %v", err)
+ }
+
+ // Verify conversation was created
+ conv, err := eng.store.GetConversationBySessionKey(ctx, "agent:boot1")
+ if err != nil {
+ t.Fatalf("GetConversation: %v", err)
+ }
+ if conv == nil {
+ t.Fatal("expected conversation to exist after bootstrap")
+ }
+
+ // Verify messages were stored
+ stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if err != nil {
+ t.Fatalf("GetMessages: %v", err)
+ }
+ if len(stored) != 3 {
+ t.Fatalf("expected 3 stored messages, got %d", len(stored))
+ }
+ if stored[0].Content != "hello" {
+ t.Errorf("stored[0].Content = %q, want 'hello'", stored[0].Content)
+ }
+
+ // Verify context_items were populated
+ items, err := eng.store.GetContextItems(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("GetContextItems: %v", err)
+ }
+ if len(items) != 3 {
+ t.Fatalf("expected 3 context items, got %d", len(items))
+ }
+}
+
+func TestEngineBootstrapEmpty(t *testing.T) {
+ eng := newTestEngine(t)
+ ctx := context.Background()
+
+ err := eng.Bootstrap(ctx, "agent:empty", nil)
+ if err != nil {
+ t.Fatalf("Bootstrap empty: %v", err)
+ }
+
+ // No conversation should be created for empty messages
+ conv, _ := eng.store.GetConversationBySessionKey(ctx, "agent:empty")
+ if conv != nil {
+ t.Error("expected no conversation for empty bootstrap")
+ }
+}
+
+func TestEngineBootstrapIdempotent(t *testing.T) {
+ eng := newTestEngine(t)
+ ctx := context.Background()
+
+ msgs := []Message{
+ {Role: "user", Content: "hello", TokenCount: 3},
+ {Role: "assistant", Content: "world", TokenCount: 3},
+ }
+
+ // Bootstrap twice with same messages
+ eng.Bootstrap(ctx, "agent:idem", msgs)
+ eng.Bootstrap(ctx, "agent:idem", msgs)
+
+ // Should still have exactly 2 messages (no duplicates)
+ conv, _ := eng.store.GetConversationBySessionKey(ctx, "agent:idem")
+ if conv == nil {
+ t.Fatal("expected conversation")
+ }
+ stored, _ := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if len(stored) != 2 {
+ t.Errorf("expected 2 messages (idempotent), got %d", len(stored))
+ }
+}
+
+func TestBootstrapRepairsMissingReasoningContent(t *testing.T) {
+ eng := newTestEngine(t)
+ ctx := context.Background()
+ sessionKey := "agent:repair-reasoning"
+
+ conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey)
+ if err != nil {
+ t.Fatalf("GetOrCreateConversation: %v", err)
+ }
+
+ userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3)
+ if err != nil {
+ t.Fatalf("AddMessage user: %v", err)
+ }
+
+ assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3)
+ if err != nil {
+ t.Fatalf("AddMessage assistant: %v", err)
+ }
+
+ err = eng.store.AppendContextMessages(
+ ctx,
+ conv.ConversationID,
+ []int64{userMsg.ID, assistantMsg.ID},
+ )
+ if err != nil {
+ t.Fatalf("AppendContextMessages: %v", err)
+ }
+
+ err = eng.Bootstrap(ctx, sessionKey, []Message{
+ {Role: "user", Content: "hello", TokenCount: 3},
+ {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3},
+ })
+ if err != nil {
+ t.Fatalf("Bootstrap: %v", err)
+ }
+
+ stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if err != nil {
+ t.Fatalf("GetMessages: %v", err)
+ }
+ if len(stored) != 2 {
+ t.Fatalf("stored messages = %d, want 2", len(stored))
+ }
+ if stored[1].ReasoningContent != "let me think this through" {
+ t.Errorf(
+ "stored[1].ReasoningContent = %q, want %q",
+ stored[1].ReasoningContent,
+ "let me think this through",
+ )
+ }
+}
+
+func TestBootstrapRepairsMissingReasoningContentWithoutDroppingSummaries(t *testing.T) {
+ eng := newTestEngine(t)
+ ctx := context.Background()
+ sessionKey := "agent:repair-reasoning-summary"
+
+ conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey)
+ if err != nil {
+ t.Fatalf("GetOrCreateConversation: %v", err)
+ }
+
+ userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3)
+ if err != nil {
+ t.Fatalf("AddMessage user: %v", err)
+ }
+ assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3)
+ if err != nil {
+ t.Fatalf("AddMessage assistant: %v", err)
+ }
+
+ err = eng.store.AppendContextMessages(
+ ctx,
+ conv.ConversationID,
+ []int64{userMsg.ID, assistantMsg.ID},
+ )
+ if err != nil {
+ t.Fatalf("AppendContextMessages: %v", err)
+ }
+
+ summary, err := eng.store.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "summary before repair",
+ TokenCount: 10,
+ })
+ if err != nil {
+ t.Fatalf("CreateSummary: %v", err)
+ }
+
+ err = eng.store.AppendContextSummary(ctx, conv.ConversationID, summary.SummaryID)
+ if err != nil {
+ t.Fatalf("AppendContextSummary: %v", err)
+ }
+
+ err = eng.Bootstrap(ctx, sessionKey, []Message{
+ {Role: "user", Content: "hello", TokenCount: 3},
+ {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3},
+ })
+ if err != nil {
+ t.Fatalf("Bootstrap: %v", err)
+ }
+
+ stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if err != nil {
+ t.Fatalf("GetMessages: %v", err)
+ }
+ if len(stored) != 2 {
+ t.Fatalf("stored messages = %d, want 2", len(stored))
+ }
+ if stored[1].ReasoningContent != "let me think this through" {
+ t.Errorf(
+ "stored[1].ReasoningContent = %q, want %q",
+ stored[1].ReasoningContent,
+ "let me think this through",
+ )
+ }
+
+ summaries, err := eng.store.GetSummariesByConversation(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("GetSummariesByConversation: %v", err)
+ }
+ if len(summaries) != 1 {
+ t.Fatalf("summaries = %d, want 1", len(summaries))
+ }
+ if summaries[0].SummaryID != summary.SummaryID {
+ t.Errorf("SummaryID = %q, want %q", summaries[0].SummaryID, summary.SummaryID)
+ }
+
+ items, err := eng.store.GetContextItems(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("GetContextItems: %v", err)
+ }
+ if len(items) != 3 {
+ t.Fatalf("context items = %d, want 3", len(items))
+ }
+ if items[2].ItemType != "summary" || items[2].SummaryID != summary.SummaryID {
+ t.Errorf("summary context item = %+v, want summary %q", items[2], summary.SummaryID)
+ }
+}
+
+func TestBootstrapRepairsMissingReasoningContentOnPrefixBeforeAppendingDelta(t *testing.T) {
+ eng := newTestEngine(t)
+ ctx := context.Background()
+ sessionKey := "agent:repair-reasoning-prefix"
+
+ conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey)
+ if err != nil {
+ t.Fatalf("GetOrCreateConversation: %v", err)
+ }
+
+ userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3)
+ if err != nil {
+ t.Fatalf("AddMessage user: %v", err)
+ }
+ assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3)
+ if err != nil {
+ t.Fatalf("AddMessage assistant: %v", err)
+ }
+
+ err = eng.store.AppendContextMessages(
+ ctx,
+ conv.ConversationID,
+ []int64{userMsg.ID, assistantMsg.ID},
+ )
+ if err != nil {
+ t.Fatalf("AppendContextMessages: %v", err)
+ }
+
+ err = eng.Bootstrap(ctx, sessionKey, []Message{
+ {Role: "user", Content: "hello", TokenCount: 3},
+ {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3},
+ {Role: "user", Content: "follow-up", TokenCount: 2},
+ })
+ if err != nil {
+ t.Fatalf("Bootstrap: %v", err)
+ }
+
+ stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if err != nil {
+ t.Fatalf("GetMessages: %v", err)
+ }
+ if len(stored) != 3 {
+ t.Fatalf("stored messages = %d, want 3", len(stored))
+ }
+ if stored[1].ReasoningContent != "let me think this through" {
+ t.Errorf(
+ "stored[1].ReasoningContent = %q, want %q",
+ stored[1].ReasoningContent,
+ "let me think this through",
+ )
+ }
+ if stored[2].Content != "follow-up" {
+ t.Errorf("stored[2].Content = %q, want %q", stored[2].Content, "follow-up")
+ }
+
+ items, err := eng.store.GetContextItems(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("GetContextItems: %v", err)
+ }
+ if len(items) != 3 {
+ t.Fatalf("context items = %d, want 3", len(items))
+ }
+ if items[2].ItemType != "message" || items[2].MessageID != stored[2].ID {
+ t.Errorf("last context item = %+v, want appended message %d", items[2], stored[2].ID)
+ }
+}
+
+func TestEngineBootstrapDelta(t *testing.T) {
+ eng := newTestEngine(t)
+ ctx := context.Background()
+
+ // First bootstrap with 2 messages
+ msgs1 := []Message{
+ {Role: "user", Content: "hello", TokenCount: 3},
+ {Role: "assistant", Content: "world", TokenCount: 3},
+ }
+ eng.Bootstrap(ctx, "agent:delta", msgs1)
+
+ // Second bootstrap with 4 messages (2 existing + 2 new)
+ msgs2 := []Message{
+ {Role: "user", Content: "hello", TokenCount: 3},
+ {Role: "assistant", Content: "world", TokenCount: 3},
+ {Role: "user", Content: "new question", TokenCount: 5},
+ {Role: "assistant", Content: "new answer", TokenCount: 5},
+ }
+ eng.Bootstrap(ctx, "agent:delta", msgs2)
+
+ conv, _ := eng.store.GetConversationBySessionKey(ctx, "agent:delta")
+ if conv == nil {
+ t.Fatal("expected conversation")
+ }
+ stored, _ := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if len(stored) != 4 {
+ t.Errorf("expected 4 messages (delta), got %d", len(stored))
+ }
+}
+
+func TestBootstrapPopulatesContextItems(t *testing.T) {
+ // Bootstrap ingests messages and populates context_items
+ e := newTestEngine(t)
+ ctx := context.Background()
+
+ messages := []Message{
+ {Role: "user", Content: "hello from bootstrap test", TokenCount: 10},
+ {Role: "assistant", Content: "hi there", TokenCount: 5},
+ {Role: "user", Content: "how are you", TokenCount: 5},
+ {Role: "assistant", Content: "doing well", TokenCount: 5},
+ {Role: "user", Content: "great news", TokenCount: 5},
+ {Role: "assistant", Content: "awesome", TokenCount: 5},
+ {Role: "user", Content: "lets code", TokenCount: 5},
+ {Role: "assistant", Content: "sure thing", TokenCount: 5},
+ }
+
+ // Bootstrap should ingest and rebuild context_items
+ err := e.Bootstrap(ctx, "test-bootstrap-rebuild", messages)
+ if err != nil {
+ t.Fatalf("Bootstrap: %v", err)
+ }
+
+ // After bootstrap, context_items should be populated
+ conv, _ := e.store.GetOrCreateConversation(ctx, "test-bootstrap-rebuild")
+ items, err := e.store.GetContextItems(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("GetContextItems: %v", err)
+ }
+
+ if len(items) == 0 {
+ t.Error("expected context_items to be populated after Bootstrap, got 0 items")
+ }
+
+ // Should have one item per message
+ if len(items) != len(messages) {
+ t.Errorf("expected %d context items, got %d", len(messages), len(items))
+ }
+}
+
+func TestBootstrapDeltaPreservesOrder(t *testing.T) {
+ // When Bootstrap does delta ingest, context_items should maintain
+ // correct order with new messages appended after anchor.
+ e := newTestEngine(t)
+ ctx := context.Background()
+ sessionKey := "test-bootstrap-delta-order"
+
+ // First: bootstrap with 4 messages
+ initialMsgs := []Message{
+ {Role: "user", Content: "msg1", TokenCount: 5},
+ {Role: "assistant", Content: "msg2", TokenCount: 5},
+ {Role: "user", Content: "msg3", TokenCount: 5},
+ {Role: "assistant", Content: "msg4", TokenCount: 5},
+ }
+ err := e.Bootstrap(ctx, sessionKey, initialMsgs)
+ if err != nil {
+ t.Fatalf("first Bootstrap: %v", err)
+ }
+
+ conv, _ := e.store.GetOrCreateConversation(ctx, sessionKey)
+ items1, _ := e.store.GetContextItems(ctx, conv.ConversationID)
+ if len(items1) != 4 {
+ t.Fatalf("after first bootstrap: expected 4 items, got %d", len(items1))
+ }
+
+ // Now bootstrap again with 6 messages (4 existing + 2 new)
+ // The delta (msg5, msg6) should be appended
+ updatedMsgs := []Message{
+ {Role: "user", Content: "msg1", TokenCount: 5},
+ {Role: "assistant", Content: "msg2", TokenCount: 5},
+ {Role: "user", Content: "msg3", TokenCount: 5},
+ {Role: "assistant", Content: "msg4", TokenCount: 5},
+ {Role: "user", Content: "msg5", TokenCount: 5},
+ {Role: "assistant", Content: "msg6", TokenCount: 5},
+ }
+ err = e.Bootstrap(ctx, sessionKey, updatedMsgs)
+ if err != nil {
+ t.Fatalf("second Bootstrap: %v", err)
+ }
+
+ items2, _ := e.store.GetContextItems(ctx, conv.ConversationID)
+ if len(items2) != 6 {
+ t.Errorf("after delta bootstrap: expected 6 items, got %d", len(items2))
+ }
+}
+
+func TestBootstrapHistoryEditFirstMessageChanged(t *testing.T) {
+ // When the first message changes (anchor = -1), Bootstrap should rebuild
+ // from scratch without panicking (regression test for index out of range [-1])
+ e := newTestEngine(t)
+ ctx := context.Background()
+ sessionKey := "test-bootstrap-history-edit"
+
+ // First: bootstrap with some messages
+ initialMsgs := []Message{
+ {Role: "user", Content: "original first", TokenCount: 5},
+ {Role: "assistant", Content: "response", TokenCount: 5},
+ {Role: "user", Content: "question", TokenCount: 5},
+ }
+ err := e.Bootstrap(ctx, sessionKey, initialMsgs)
+ if err != nil {
+ t.Fatalf("first Bootstrap: %v", err)
+ }
+
+ // Now bootstrap with completely different messages (first message changed)
+ // This should NOT panic - it should rebuild from scratch
+ editedMsgs := []Message{
+ {Role: "user", Content: "DIFFERENT first message", TokenCount: 5},
+ {Role: "assistant", Content: "DIFFERENT response", TokenCount: 5},
+ {Role: "user", Content: "DIFFERENT question", TokenCount: 5},
+ }
+ err = e.Bootstrap(ctx, sessionKey, editedMsgs)
+ if err != nil {
+ t.Fatalf("second Bootstrap (history edit): %v", err)
+ }
+
+ conv, _ := e.store.GetOrCreateConversation(ctx, sessionKey)
+ stored, _ := e.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+
+ // Should have the NEW messages (history was rebuilt)
+ if len(stored) != 3 {
+ t.Errorf("expected 3 messages after history edit, got %d", len(stored))
+ }
+ if len(stored) > 0 && stored[0].Content != "DIFFERENT first message" {
+ t.Errorf("first message = %q, want 'DIFFERENT first message'", stored[0].Content)
+ }
+}
+
+func TestBootstrapSameContentDifferentTokenCountNoRebuild(t *testing.T) {
+ // Bootstrap should NOT rebuild when content is identical but TokenCount differs.
+ // This happens when TokenCount is re-estimated (e.g., via tokenizer.EstimateMessageTokens)
+ // during bootstrap, which may give slightly different values.
+ e := newTestEngine(t)
+ ctx := context.Background()
+ sessionKey := "test-bootstrap-token-diff"
+
+ // First: bootstrap with some messages
+ initialMsgs := []Message{
+ {Role: "user", Content: "hello world", TokenCount: 10},
+ {Role: "assistant", Content: "hi there", TokenCount: 5},
+ }
+ err := e.Bootstrap(ctx, sessionKey, initialMsgs)
+ if err != nil {
+ t.Fatalf("first Bootstrap: %v", err)
+ }
+
+ conv, _ := e.store.GetOrCreateConversation(ctx, sessionKey)
+ storedBefore, _ := e.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+
+ // Second: bootstrap with SAME content but DIFFERENT TokenCount
+ // This should be a no-op (not rebuild)
+ sameContentMsgs := []Message{
+ {Role: "user", Content: "hello world", TokenCount: 999}, // Different token count!
+ {Role: "assistant", Content: "hi there", TokenCount: 888}, // Different token count!
+ }
+ err = e.Bootstrap(ctx, sessionKey, sameContentMsgs)
+ if err != nil {
+ t.Fatalf("second Bootstrap: %v", err)
+ }
+
+ storedAfter, _ := e.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+
+ // Should have same number of messages (no rebuild)
+ if len(storedAfter) != len(storedBefore) {
+ t.Errorf("expected %d messages (no rebuild), got %d", len(storedBefore), len(storedAfter))
+ }
+
+ // Message IDs should be the same (no delete+re-ingest)
+ for i := range storedBefore {
+ if storedBefore[i].ID != storedAfter[i].ID {
+ t.Errorf("message %d ID changed: before=%d, after=%d (should be no-op)",
+ i, storedBefore[i].ID, storedAfter[i].ID)
+ }
+ }
+}
+
+// --- Session Mutex ---
+
+func TestEngineSessionMutexSharded(t *testing.T) {
+ eng := newTestEngine(t)
+
+ // Same session key should always return the same mutex (deterministic hash)
+ mu1 := eng.getSessionMutex("agent:test")
+ mu2 := eng.getSessionMutex("agent:test")
+ if mu1 != mu2 {
+ t.Error("expected same mutex for same session key")
+ }
+
+ // Different session keys may share the same shard (hash collision)
+ // This is expected behavior - we just need bounded memory, not unique locks
+ mu3 := eng.getSessionMutex("agent:other")
+
+ // Both mutexes should be valid and usable
+ mu1.Lock()
+ mu1.Unlock()
+ mu3.Lock()
+ mu3.Unlock()
+}
+
+func TestEngineSessionMutexBoundedMemory(t *testing.T) {
+ // Verify that session mutexes use bounded memory (256 shards)
+ eng := newTestEngine(t)
+
+ // Get mutexes for many different sessions
+ seen := make(map[*sync.Mutex]bool)
+ for i := 0; i < 1000; i++ {
+ sessionKey := fmt.Sprintf("agent:session-%d", i)
+ mu := eng.getSessionMutex(sessionKey)
+ seen[mu] = true
+ }
+
+ // With 256 shards and 1000 sessions, we should see at most 256 unique mutexes
+ // (likely fewer due to hash collisions)
+ if len(seen) > 256 {
+ t.Errorf("expected at most 256 unique mutexes (shards), got %d", len(seen))
+ }
+}
+
+func TestEngineSessionMutexConsistentHash(t *testing.T) {
+ // Same session key should always hash to the same shard
+ eng := newTestEngine(t)
+
+ sessionKey := "agent:consistent-hash-test"
+ mu1 := eng.getSessionMutex(sessionKey)
+ mu2 := eng.getSessionMutex(sessionKey)
+ mu3 := eng.getSessionMutex(sessionKey)
+
+ if mu1 != mu2 || mu2 != mu3 {
+ t.Error("hash function should be deterministic - same key must map to same shard")
+ }
+}
+
+// --- Summary Role ---
+
+func TestAssemblerSummaryRoleNotUser(t *testing.T) {
+ // Summaries should use "system" role, not "user"
+ eng := newTestEngine(t)
+ ctx := context.Background()
+
+ // Ingest messages
+ eng.Ingest(ctx, "agent:summary-role-test", []Message{
+ {Role: "user", Content: "hello", TokenCount: 5},
+ {Role: "assistant", Content: "world", TokenCount: 5},
+ })
+
+ conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:summary-role-test")
+
+ // Create a summary and add it to context
+ sum, err := eng.store.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Content: "Test summary content",
+ TokenCount: 10,
+ Kind: SummaryKindCondensed,
+ Depth: 1,
+ })
+ if err != nil {
+ t.Fatalf("CreateSummary: %v", err)
+ }
+ eng.store.AppendContextSummary(ctx, conv.ConversationID, sum.SummaryID)
+
+ // Assemble and check summary message role
+ result, err := eng.Assemble(ctx, "agent:summary-role-test", AssembleInput{Budget: 1000})
+ if err != nil {
+ t.Fatalf("Assemble: %v", err)
+ }
+
+ // Find the summary message (should have XML content with )
+ for _, msg := range result.Messages {
+ if strings.Contains(msg.Content, "= 5
+ // This tests the bug: when depth=2 is missing, the loop breaks and depth=3 is never checked
+ // Need > FreshTailCount(32) summaries so they are not all in fresh tail
+ // Depth 0: 3 summaries (not enough), Depth 1: 3 summaries (not enough)
+ // Depth 2: 0 summaries (missing), Depth 3: 40 summaries (enough)
+ depths := []int{0, 0, 0, 1, 1, 1}
+ for i := 0; i < 40; i++ {
+ depths = append(depths, 3)
+ }
+ now := time.Now().UTC()
+
+ for i, depth := range depths {
+ sum, createErr := e.store.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: depth,
+ Content: fmt.Sprintf("summary depth %d #%d", depth, i),
+ TokenCount: 10,
+ EarliestAt: &now,
+ LatestAt: &now,
+ })
+ if createErr != nil {
+ t.Fatalf("CreateSummary: %v", createErr)
+ }
+ // Add to context items (not in fresh tail)
+ if appendErr := e.store.AppendContextSummary(ctx, conv.ConversationID, sum.SummaryID); appendErr != nil {
+ t.Fatalf("AppendContextSummary: %v", appendErr)
+ }
+ }
+
+ // Initialize compaction engine (lazy init)
+ e.initCompactionOnce()
+
+ // Call selectShallowestCondensationCandidate
+ candidates, err := e.compaction.selectShallowestCondensationCandidate(ctx, conv.ConversationID, false)
+ if err != nil {
+ t.Fatalf("selectShallowestCondensationCandidate: %v", err)
+ }
+
+ // Should find depth=0 (shallowest) with 5 summaries
+ if candidates == nil {
+ t.Fatal("expected candidates, got nil")
+ }
+ if len(candidates) < CondensedMinFanout {
+ t.Errorf("expected at least %d candidates, got %d", CondensedMinFanout, len(candidates))
+ }
+
+ // Verify all returned summaries have the same depth
+ if len(candidates) > 0 {
+ expectedDepth := candidates[0].Depth
+ for _, c := range candidates[1:] {
+ if c.Depth != expectedDepth {
+ t.Errorf("candidates have mixed depths: %d vs %d", expectedDepth, c.Depth)
+ }
+ }
+ }
+}
diff --git a/pkg/seahorse/short_retrieval.go b/pkg/seahorse/short_retrieval.go
new file mode 100644
index 000000000..3e94eec14
--- /dev/null
+++ b/pkg/seahorse/short_retrieval.go
@@ -0,0 +1,212 @@
+package seahorse
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// ParseLastDuration parses a "last" duration string like "6h", "7d", "2w", "1m".
+// Returns the duration and nil error, or zero and error if invalid.
+func ParseLastDuration(s string) (time.Duration, error) {
+ if s == "" {
+ return 0, fmt.Errorf("empty duration")
+ }
+
+ re := regexp.MustCompile(`^(\d+)([hdwm])$`)
+ matches := re.FindStringSubmatch(s)
+ if matches == nil {
+ return 0, fmt.Errorf("invalid duration format: %q (use format like 6h, 7d, 2w, 1m)", s)
+ }
+
+ value, _ := strconv.Atoi(matches[1])
+ unit := matches[2]
+
+ switch unit {
+ case "h":
+ return time.Duration(value) * time.Hour, nil
+ case "d":
+ return time.Duration(value) * 24 * time.Hour, nil
+ case "w":
+ return time.Duration(value) * 7 * 24 * time.Hour, nil
+ case "m":
+ return time.Duration(value) * 30 * 24 * time.Hour, nil
+ default:
+ return 0, fmt.Errorf("unknown unit: %q", unit)
+ }
+}
+
+// GrepInput controls search across summaries and messages.
+type GrepInput struct {
+ Pattern string `json:"pattern"`
+ Scope string `json:"scope,omitempty"` // "both" (default), "summary", or "message"
+ Role string `json:"role,omitempty"` // "user", "assistant", or "" (all)
+ AllConversations bool `json:"allConversations,omitempty"`
+ Since *time.Time `json:"since,omitempty"`
+ Before *time.Time `json:"before,omitempty"`
+ Last string `json:"last,omitempty"` // shortcut: "6h", "7d", "2w", "1m"
+ Limit int `json:"limit,omitempty"`
+}
+
+// GrepResult contains search results.
+type GrepResult struct {
+ Success bool `json:"success"`
+ Summaries []GrepSummaryResult `json:"summaries"`
+ Messages []GrepMessageResult `json:"messages"`
+ TotalSummaries int `json:"totalSummaries"`
+ TotalMessages int `json:"totalMessages"`
+ Hint string `json:"hint,omitempty"`
+}
+
+// GrepSummaryResult is a summary match from grep.
+type GrepSummaryResult struct {
+ ID string `json:"id"`
+ Content string `json:"content"`
+ Depth int `json:"depth"`
+ Kind SummaryKind `json:"kind"`
+ ConversationID int64 `json:"conversationId"`
+ // Rank is the bm25 relevance score (negative value, lower = better match).
+ // Examples: -5.0 = excellent match, -2.0 = good match, -0.5 = partial match.
+ Rank float64 `json:"rank,omitempty"`
+}
+
+// GrepMessageResult is a message match from grep.
+type GrepMessageResult struct {
+ ID int64 `json:"id,string"`
+ Snippet string `json:"snippet"`
+ Role string `json:"role"`
+ ConversationID int64 `json:"conversationId"`
+ Rank float64 `json:"rank,omitempty"` // Relevance score (more negative = better match)
+}
+
+// ExpandMessagesResult contains expanded messages.
+type ExpandMessagesResult struct {
+ Messages []Message `json:"messages"`
+ TokenCount int `json:"tokenCount"`
+}
+
+// Grep searches summaries and messages for matching content.
+func (r *RetrievalEngine) Grep(ctx context.Context, input GrepInput) (*GrepResult, error) {
+ if input.Pattern == "" {
+ return nil, fmt.Errorf("grep: pattern is required")
+ }
+
+ limit := input.Limit
+ if limit == 0 {
+ limit = 20
+ }
+
+ // Handle Last parameter: convert to Since
+ since := input.Since
+ if input.Last != "" {
+ dur, err := ParseLastDuration(input.Last)
+ if err != nil {
+ return nil, fmt.Errorf("grep: invalid last: %w", err)
+ }
+ t := time.Now().UTC().Add(-dur)
+ since = &t
+ }
+
+ // Auto-detect mode: use LIKE if pattern contains %, otherwise full-text
+ mode := ""
+ if strings.Contains(input.Pattern, "%") {
+ mode = "like"
+ }
+
+ searchInput := SearchInput{
+ Pattern: input.Pattern,
+ Mode: mode,
+ Role: input.Role,
+ AllConversations: input.AllConversations,
+ Since: since,
+ Before: input.Before,
+ Limit: limit,
+ }
+
+ result := &GrepResult{
+ Success: true,
+ Summaries: make([]GrepSummaryResult, 0),
+ Messages: make([]GrepMessageResult, 0),
+ TotalSummaries: 0,
+ TotalMessages: 0,
+ }
+
+ // Determine scope
+ scope := input.Scope
+ if scope == "" {
+ scope = "both"
+ }
+
+ // Search summaries if requested
+ if scope == "both" || scope == "summary" {
+ sumResults, err := r.store.SearchSummaries(ctx, searchInput)
+ if err != nil {
+ return nil, fmt.Errorf("search summaries: %w", err)
+ }
+ for _, sr := range sumResults {
+ if sr.SummaryID != "" {
+ result.Summaries = append(result.Summaries, GrepSummaryResult{
+ ID: sr.SummaryID,
+ Content: sr.Content,
+ Depth: sr.Depth,
+ Kind: sr.Kind,
+ ConversationID: sr.ConversationID,
+ Rank: sr.Rank,
+ })
+ }
+ }
+ if len(sumResults) > 0 {
+ result.TotalSummaries = sumResults[0].TotalCount
+ }
+ }
+
+ // Search messages if requested
+ if scope == "both" || scope == "message" {
+ msgResults, err := r.store.SearchMessages(ctx, searchInput)
+ if err != nil {
+ return nil, fmt.Errorf("search messages: %w", err)
+ }
+ for _, sr := range msgResults {
+ if sr.MessageID > 0 {
+ result.Messages = append(result.Messages, GrepMessageResult{
+ ID: sr.MessageID,
+ Snippet: sr.Snippet,
+ Role: sr.Role,
+ ConversationID: sr.ConversationID,
+ Rank: sr.Rank,
+ })
+ }
+ }
+ if len(msgResults) > 0 {
+ result.TotalMessages = msgResults[0].TotalCount
+ }
+ }
+
+ // Add hint if no results
+ if len(result.Summaries) == 0 && len(result.Messages) == 0 {
+ result.Hint = "No matches. Try: %keyword% for fuzzy search, or all_conversations: true"
+ }
+
+ return result, nil
+}
+
+// ExpandMessages retrieves full message content by IDs.
+func (r *RetrievalEngine) ExpandMessages(ctx context.Context, messageIDs []int64) (*ExpandMessagesResult, error) {
+ result := &ExpandMessagesResult{
+ Messages: make([]Message, 0, len(messageIDs)),
+ }
+
+ for _, msgID := range messageIDs {
+ msg, err := r.store.GetMessageByID(ctx, msgID)
+ if err != nil {
+ continue
+ }
+ result.Messages = append(result.Messages, *msg)
+ result.TokenCount += msg.TokenCount
+ }
+
+ return result, nil
+}
diff --git a/pkg/seahorse/short_retrieval_test.go b/pkg/seahorse/short_retrieval_test.go
new file mode 100644
index 000000000..9d9bc3640
--- /dev/null
+++ b/pkg/seahorse/short_retrieval_test.go
@@ -0,0 +1,362 @@
+package seahorse
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+)
+
+// --- Retrieval Tests ---
+
+func newTestRetrieval(t *testing.T) (*RetrievalEngine, *Store, int64) {
+ t.Helper()
+ s := openTestStore(t)
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "test:retrieval")
+ return &RetrievalEngine{store: s}, s, conv.ConversationID
+}
+
+func TestRetrievalGrepSummaries(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "数据库连接配置说明",
+ TokenCount: 50,
+ })
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "API endpoint documentation",
+ TokenCount: 50,
+ })
+
+ // FTS5 search (trigram, needs >= 3 chars)
+ results, err := r.Grep(ctx, GrepInput{
+ Pattern: "数据库连",
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ if len(results.Summaries) == 0 {
+ t.Error("expected at least 1 FTS result")
+ }
+
+ // LIKE search with wildcard
+ results, err = r.Grep(ctx, GrepInput{
+ Pattern: "%endpoint%",
+ })
+ if err != nil {
+ t.Fatalf("Grep LIKE: %v", err)
+ }
+ if len(results.Summaries) == 0 {
+ t.Error("expected at least 1 LIKE result")
+ }
+}
+
+func TestRetrievalGrepMessages(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ s.AddMessage(ctx, convID, "user", "find this message about testing", 5)
+ s.AddMessage(ctx, convID, "user", "unrelated content here", 5)
+
+ results, err := r.Grep(ctx, GrepInput{
+ Pattern: "testing",
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ if len(results.Messages) == 0 {
+ t.Error("expected at least 1 result for 'testing'")
+ }
+}
+
+func TestRetrievalExpandMessages(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ msg, _ := s.AddMessage(ctx, convID, "user", "expand this message", 10)
+
+ result, err := r.ExpandMessages(ctx, []int64{msg.ID})
+ if err != nil {
+ t.Fatalf("ExpandMessages: %v", err)
+ }
+ if len(result.Messages) != 1 {
+ t.Errorf("Messages = %d, want 1", len(result.Messages))
+ }
+ if result.Messages[0].Content != "expand this message" {
+ t.Errorf("Content = %q, want 'expand this message'", result.Messages[0].Content)
+ }
+}
+
+func TestRetrievalExpandMultipleMessages(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ msg1, _ := s.AddMessage(ctx, convID, "user", "first message", 10)
+ msg2, _ := s.AddMessage(ctx, convID, "assistant", "second message", 10)
+ msg3, _ := s.AddMessage(ctx, convID, "user", "third message", 10)
+
+ result, err := r.ExpandMessages(ctx, []int64{msg1.ID, msg2.ID, msg3.ID})
+ if err != nil {
+ t.Fatalf("ExpandMessages: %v", err)
+ }
+ if len(result.Messages) != 3 {
+ t.Errorf("Messages = %d, want 3", len(result.Messages))
+ }
+ if result.TokenCount != 30 {
+ t.Errorf("TokenCount = %d, want 30", result.TokenCount)
+ }
+}
+
+func TestRetrievalGrepWithTimeFilter(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ now := time.Now().UTC()
+ before := now.Add(-2 * time.Hour)
+
+ // Create messages at different times
+ s.AddMessage(ctx, convID, "user", "old message about auth", 5)
+ s.AddMessage(ctx, convID, "user", "recent message about auth", 5)
+
+ // Search with time filter
+ results, err := r.Grep(ctx, GrepInput{
+ Pattern: "auth",
+ Since: &before,
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ _ = results // Just verify no error
+}
+
+func TestRetrievalGrepAllConversations(t *testing.T) {
+ r, s, _ := newTestRetrieval(t)
+ ctx := context.Background()
+
+ // Create another conversation
+ conv2, _ := s.GetOrCreateConversation(ctx, "test:retrieval2")
+
+ // Add messages to both
+ s.AddMessage(ctx, conv2.ConversationID, "user", "unique keyword xyz", 5)
+
+ // Search all conversations
+ results, err := r.Grep(ctx, GrepInput{
+ Pattern: "xyz",
+ AllConversations: true,
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ if len(results.Messages) == 0 {
+ t.Error("expected to find message in other conversation")
+ }
+}
+
+// --- Last Duration Parsing Tests ---
+
+func TestParseLastDuration(t *testing.T) {
+ tests := []struct {
+ input string
+ wantDur time.Duration
+ wantErr bool
+ }{
+ {"6h", 6 * time.Hour, false},
+ {"1d", 24 * time.Hour, false},
+ {"7d", 7 * 24 * time.Hour, false},
+ {"2w", 14 * 24 * time.Hour, false},
+ {"1m", 30 * 24 * time.Hour, false}, // month = 30 days
+ {"3m", 90 * 24 * time.Hour, false},
+ {"", 0, true},
+ {"invalid", 0, true},
+ {"5x", 0, true}, // unknown unit
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got, err := ParseLastDuration(tt.input)
+ if tt.wantErr {
+ if err == nil {
+ t.Error("expected error, got nil")
+ }
+ } else {
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != tt.wantDur {
+ t.Errorf("ParseLastDuration(%q) = %v, want %v", tt.input, got, tt.wantDur)
+ }
+ }
+ })
+ }
+}
+
+// --- Role Filter Tests ---
+
+func TestRetrievalGrepRoleFilter(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ s.AddMessage(ctx, convID, "user", "user message about alpha", 5)
+ s.AddMessage(ctx, convID, "assistant", "assistant reply about alpha", 5)
+ s.AddMessage(ctx, convID, "user", "another user message", 5)
+
+ // Search all roles
+ allResults, err := r.Grep(ctx, GrepInput{
+ Pattern: "alpha",
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ if len(allResults.Messages) != 2 {
+ t.Errorf("expected 2 messages, got %d", len(allResults.Messages))
+ }
+
+ // Search user only
+ userResults, err := r.Grep(ctx, GrepInput{
+ Pattern: "alpha",
+ Role: "user",
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ if len(userResults.Messages) != 1 {
+ t.Errorf("expected 1 user message, got %d", len(userResults.Messages))
+ }
+ if userResults.Messages[0].Role != "user" {
+ t.Errorf("expected role=user, got %s", userResults.Messages[0].Role)
+ }
+
+ // Search assistant only
+ assistantResults, err := r.Grep(ctx, GrepInput{
+ Pattern: "alpha",
+ Role: "assistant",
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ if len(assistantResults.Messages) != 1 {
+ t.Errorf("expected 1 assistant message, got %d", len(assistantResults.Messages))
+ }
+}
+
+// --- Last Parameter Tests ---
+
+func TestRetrievalGrepWithLast(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ // Add messages (we can't control timestamps in SQLite easily,
+ // but we can verify the parameter is parsed correctly)
+ s.AddMessage(ctx, convID, "user", "recent message about testing", 5)
+
+ // Test that Last parameter is converted to Since
+ results, err := r.Grep(ctx, GrepInput{
+ Pattern: "testing",
+ Last: "1d", // last 1 day
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ // Should still find the message since it's recent
+ if len(results.Messages) == 0 {
+ t.Error("expected to find recent message")
+ }
+}
+
+// TestRetrievalGrepRoleFilterWithSummaries tests that role filter works when
+// searching both summaries and messages (summaries don't have role column).
+func TestRetrievalGrepRoleFilterWithSummaries(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ // Create a summary (no role column)
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "summary about testing",
+ TokenCount: 50,
+ })
+
+ // Add messages with different roles
+ s.AddMessage(ctx, convID, "user", "user message about testing", 5)
+ s.AddMessage(ctx, convID, "assistant", "assistant reply about testing", 5)
+
+ // Search with role filter and scope=both (default), using LIKE mode (%)
+ // This should NOT error even though summaries don't have role column
+ bothResults, err := r.Grep(ctx, GrepInput{
+ Pattern: "%testing%", // LIKE mode to trigger the bug
+ Role: "user",
+ Scope: "both",
+ })
+ if err != nil {
+ t.Fatalf("Grep with role and scope=both: %v", err)
+ }
+
+ // Should only return user messages, not summaries or assistant messages
+ if len(bothResults.Messages) != 1 {
+ t.Errorf("expected 1 user message, got %d", len(bothResults.Messages))
+ }
+ if len(bothResults.Messages) > 0 && bothResults.Messages[0].Role != "user" {
+ t.Errorf("expected role=user, got %s", bothResults.Messages[0].Role)
+ }
+
+ // Summaries should be empty since they don't have roles to filter
+ // (or we could return all summaries - either is acceptable)
+}
+
+// TestRetrievalGrepTotalCounts tests that grep returns total counts.
+func TestRetrievalGrepTotalCounts(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ // Create 3 summaries
+ for i := 0; i < 3; i++ {
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: fmt.Sprintf("summary about testing %d", i),
+ TokenCount: 50,
+ })
+ }
+
+ // Add 5 messages
+ for i := 0; i < 5; i++ {
+ s.AddMessage(ctx, convID, "user", fmt.Sprintf("message about testing %d", i), 5)
+ }
+
+ // Search with limit smaller than total
+ results, err := r.Grep(ctx, GrepInput{
+ Pattern: "%testing%", // LIKE mode
+ Scope: "both",
+ Limit: 2,
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+
+ // Should return limited results
+ if len(results.Summaries) > 2 {
+ t.Errorf("expected at most 2 summaries, got %d", len(results.Summaries))
+ }
+ if len(results.Messages) > 2 {
+ t.Errorf("expected at most 2 messages, got %d", len(results.Messages))
+ }
+
+ // But total counts should reflect all matches
+ if results.TotalSummaries != 3 {
+ t.Errorf("expected TotalSummaries=3, got %d", results.TotalSummaries)
+ }
+ if results.TotalMessages != 5 {
+ t.Errorf("expected TotalMessages=5, got %d", results.TotalMessages)
+ }
+}
diff --git a/pkg/seahorse/store.go b/pkg/seahorse/store.go
new file mode 100644
index 000000000..0edbbd128
--- /dev/null
+++ b/pkg/seahorse/store.go
@@ -0,0 +1,1642 @@
+package seahorse
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// Store provides SQLite storage for seahorse.
+type Store struct {
+ db *sql.DB
+}
+
+// CreateSummaryInput holds parameters for creating a summary.
+type CreateSummaryInput struct {
+ ConversationID int64
+ Kind SummaryKind
+ Depth int
+ Content string
+ TokenCount int
+ EarliestAt *time.Time
+ LatestAt *time.Time
+ DescendantCount int
+ DescendantTokenCount int
+ SourceMessageTokens int
+ Model string
+ ParentIDs []string // For condensed: child summary IDs being condensed
+}
+
+// --- Conversation Operations ---
+
+// GetOrCreateConversation returns the conversation for a sessionKey, creating if needed.
+func (s *Store) GetOrCreateConversation(ctx context.Context, sessionKey string) (*Conversation, error) {
+ // Try to get first
+ conv, err := s.GetConversationBySessionKey(ctx, sessionKey)
+ if err != nil {
+ return nil, err
+ }
+ if conv != nil {
+ return conv, nil
+ }
+
+ // Create
+ result, err := s.db.ExecContext(ctx,
+ "INSERT INTO conversations (session_key) VALUES (?)",
+ sessionKey,
+ )
+ if err != nil {
+ // Race: another goroutine may have inserted
+ if isUniqueViolation(err) {
+ return s.GetConversationBySessionKey(ctx, sessionKey)
+ }
+ return nil, fmt.Errorf("create conversation: %w", err)
+ }
+ id, _ := result.LastInsertId()
+ return &Conversation{
+ ConversationID: id,
+ SessionKey: sessionKey,
+ }, nil
+}
+
+// GetConversationBySessionKey retrieves a conversation by session key.
+func (s *Store) GetConversationBySessionKey(ctx context.Context, sessionKey string) (*Conversation, error) {
+ var conv Conversation
+ var createdAt, updatedAt string
+ err := s.db.QueryRowContext(ctx,
+ "SELECT conversation_id, session_key, created_at, updated_at FROM conversations WHERE session_key = ?",
+ sessionKey,
+ ).Scan(&conv.ConversationID, &conv.SessionKey, &createdAt, &updatedAt)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("get conversation by session key: %w", err)
+ }
+ conv.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ conv.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
+ return &conv, nil
+}
+
+// GetSessionStatus returns status for a specific session.
+func (s *Store) GetSessionStatus(ctx context.Context, sessionKey string) (*SessionStatus, error) {
+ conv, err := s.GetConversationBySessionKey(ctx, sessionKey)
+ if err != nil {
+ return nil, err
+ }
+ if conv == nil {
+ return nil, nil
+ }
+
+ msgCount, _ := s.GetMessageCount(ctx, conv.ConversationID)
+ sumCount, _ := s.getSummaryCount(ctx, conv.ConversationID)
+ tokenCount, _ := s.GetContextTokenCount(ctx, conv.ConversationID)
+
+ oldest, newest, _ := s.getMessageTimeRange(ctx, conv.ConversationID)
+
+ return &SessionStatus{
+ SessionKey: conv.SessionKey,
+ ConversationID: conv.ConversationID,
+ Messages: msgCount,
+ TotalTokens: tokenCount,
+ Summaries: sumCount,
+ OldestAt: oldest,
+ NewestAt: newest,
+ }, nil
+}
+
+// GetAllSessionStatuses returns status for all sessions.
+func (s *Store) GetAllSessionStatuses(ctx context.Context) ([]SessionStatus, error) {
+ rows, err := s.db.QueryContext(ctx, "SELECT session_key FROM conversations")
+ if err != nil {
+ return nil, fmt.Errorf("list sessions: %w", err)
+ }
+ defer rows.Close()
+
+ var statuses []SessionStatus
+ for rows.Next() {
+ var sessionKey string
+ if err := rows.Scan(&sessionKey); err != nil {
+ continue
+ }
+ status, err := s.GetSessionStatus(ctx, sessionKey)
+ if err != nil {
+ continue
+ }
+ if status != nil {
+ statuses = append(statuses, *status)
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate sessions: %w", err)
+ }
+ return statuses, nil
+}
+
+func (s *Store) getSummaryCount(ctx context.Context, convID int64) (int, error) {
+ var count int
+ err := s.db.QueryRowContext(ctx,
+ "SELECT COUNT(*) FROM summaries WHERE conversation_id = ?",
+ convID,
+ ).Scan(&count)
+ return count, err
+}
+
+func (s *Store) getMessageTimeRange(ctx context.Context, convID int64) (time.Time, time.Time, error) {
+ var minTime, maxTime string
+ err := s.db.QueryRowContext(ctx,
+ "SELECT MIN(created_at), MAX(created_at) FROM messages WHERE conversation_id = ?",
+ convID,
+ ).Scan(&minTime, &maxTime)
+ if err != nil || minTime == "" {
+ return time.Time{}, time.Time{}, err
+ }
+ oldest, _ := time.Parse("2006-01-02 15:04:05", minTime)
+ newest, _ := time.Parse("2006-01-02 15:04:05", maxTime)
+ return oldest, newest, nil
+}
+
+// --- Message Operations ---
+
+// AddMessage appends a message to a conversation.
+func (s *Store) AddMessage(ctx context.Context, convID int64, role, content string, tokenCount int) (*Message, error) {
+ return s.AddMessageWithReasoning(ctx, convID, role, content, "", tokenCount)
+}
+
+// AddMessageWithReasoning appends a message with reasoning content to a conversation.
+func (s *Store) AddMessageWithReasoning(
+ ctx context.Context,
+ convID int64,
+ role, content, reasoningContent string,
+ tokenCount int,
+) (*Message, error) {
+ result, err := s.db.ExecContext(ctx,
+ "INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)",
+ convID, role, content, reasoningContent, tokenCount,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("add message: %w", err)
+ }
+ id, _ := result.LastInsertId()
+ return &Message{
+ ID: id,
+ ConversationID: convID,
+ Role: role,
+ Content: content,
+ ReasoningContent: reasoningContent,
+ TokenCount: tokenCount,
+ }, nil
+}
+
+// partsToReadableContent derives a readable text summary from message parts.
+// This ensures FTS5 indexing and summary formatting can access tool call information.
+func partsToReadableContent(parts []MessagePart) string {
+ var b strings.Builder
+ for i, p := range parts {
+ if i > 0 {
+ b.WriteString("\n")
+ }
+ switch p.Type {
+ case "text":
+ b.WriteString(p.Text)
+ case "tool_use":
+ fmt.Fprintf(&b, "[tool_use: %s, args: %s]", p.Name, p.Arguments)
+ case "tool_result":
+ fmt.Fprintf(&b, "[tool_result for %s: %s]", p.ToolCallID, p.Text)
+ case "media":
+ fmt.Fprintf(&b, "[media: %s (%s)]", p.MediaURI, p.MimeType)
+ default:
+ if p.Text != "" {
+ b.WriteString(p.Text)
+ }
+ }
+ }
+ return b.String()
+}
+
+// AddMessageWithParts adds a message with structured parts.
+func (s *Store) AddMessageWithParts(
+ ctx context.Context,
+ convID int64,
+ role string,
+ parts []MessagePart,
+ tokenCount int,
+) (*Message, error) {
+ return s.AddMessageWithPartsAndReasoning(ctx, convID, role, parts, "", tokenCount)
+}
+
+// AddMessageWithPartsAndReasoning adds a message with structured parts and reasoning content.
+func (s *Store) AddMessageWithPartsAndReasoning(
+ ctx context.Context,
+ convID int64,
+ role string,
+ parts []MessagePart,
+ reasoningContent string,
+ tokenCount int,
+) (*Message, error) {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return nil, fmt.Errorf("begin tx: %w", err)
+ }
+ defer tx.Rollback()
+
+ // Derive readable content from Parts for FTS5 indexing and summary formatting
+ readableContent := partsToReadableContent(parts)
+
+ result, err := tx.ExecContext(ctx,
+ "INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)",
+ convID, role, readableContent, reasoningContent, tokenCount,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("add message: %w", err)
+ }
+ msgID, _ := result.LastInsertId()
+
+ for i, p := range parts {
+ _, err = tx.ExecContext(
+ ctx,
+ `INSERT INTO message_parts (message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type, ordinal)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ msgID,
+ p.Type,
+ p.Text,
+ p.Name,
+ p.Arguments,
+ p.ToolCallID,
+ p.MediaURI,
+ p.MimeType,
+ i,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("add message part %d: %w", i, err)
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, fmt.Errorf("commit: %w", err)
+ }
+
+ // Return message with parts
+ msg := &Message{
+ ID: msgID,
+ ConversationID: convID,
+ Role: role,
+ ReasoningContent: reasoningContent,
+ TokenCount: tokenCount,
+ Parts: make([]MessagePart, len(parts)),
+ }
+ for i, p := range parts {
+ p.MessageID = msgID
+ msg.Parts[i] = p
+ }
+ return msg, nil
+}
+
+// GetMessages retrieves messages for a conversation.
+func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, beforeID int64) ([]Message, error) {
+ query := "SELECT message_id, conversation_id, role, content, reasoning_content, token_count, created_at FROM messages WHERE conversation_id = ?"
+ args := []any{convID}
+ if beforeID > 0 {
+ query += " AND message_id < ?"
+ args = append(args, beforeID)
+ }
+ query += " ORDER BY message_id ASC"
+ if limit > 0 {
+ query += " LIMIT ?"
+ args = append(args, limit)
+ }
+
+ rows, err := s.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("get messages: %w", err)
+ }
+ defer rows.Close()
+
+ var msgs []Message
+ for rows.Next() {
+ var msg Message
+ var createdAt string
+ if err := rows.Scan(
+ &msg.ID,
+ &msg.ConversationID,
+ &msg.Role,
+ &msg.Content,
+ &msg.ReasoningContent,
+ &msg.TokenCount,
+ &createdAt,
+ ); err != nil {
+ return nil, err
+ }
+ msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ msgs = append(msgs, msg)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ // Load parts for all messages
+ for i := range msgs {
+ parts, err := s.loadMessageParts(ctx, msgs[i].ID)
+ if err != nil {
+ return nil, err
+ }
+ msgs[i].Parts = parts
+ }
+
+ return msgs, nil
+}
+
+// GetMessageCount returns total message count for a conversation.
+func (s *Store) GetMessageCount(ctx context.Context, convID int64) (int, error) {
+ var count int
+ err := s.db.QueryRowContext(ctx,
+ "SELECT count(*) FROM messages WHERE conversation_id = ?", convID,
+ ).Scan(&count)
+ return count, err
+}
+
+// GetMessageByID retrieves a single message by ID.
+func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message, error) {
+ var msg Message
+ var createdAt string
+ err := s.db.QueryRowContext(
+ ctx,
+ "SELECT message_id, conversation_id, role, content, reasoning_content, token_count, created_at FROM messages WHERE message_id = ?",
+ messageID,
+ ).Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &msg.ReasoningContent, &msg.TokenCount, &createdAt)
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("message %d not found", messageID)
+ }
+ if err != nil {
+ return nil, err
+ }
+ msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ msg.Parts, _ = s.loadMessageParts(ctx, msg.ID)
+ return &msg, nil
+}
+
+// UpdateMessageReasoningContent updates reasoning_content for an existing message.
+func (s *Store) UpdateMessageReasoningContent(ctx context.Context, messageID int64, reasoningContent string) error {
+ result, err := s.db.ExecContext(
+ ctx,
+ "UPDATE messages SET reasoning_content = ? WHERE message_id = ?",
+ reasoningContent,
+ messageID,
+ )
+ if err != nil {
+ return fmt.Errorf("update message reasoning_content: %w", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("update message reasoning_content rows affected: %w", err)
+ }
+ if rowsAffected == 0 {
+ return fmt.Errorf("message %d not found", messageID)
+ }
+ return nil
+}
+
+func (s *Store) loadMessageParts(ctx context.Context, msgID int64) ([]MessagePart, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT part_id, message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type
+ FROM message_parts WHERE message_id = ? ORDER BY ordinal`,
+ msgID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var parts []MessagePart
+ for rows.Next() {
+ var p MessagePart
+ if err := rows.Scan(&p.ID, &p.MessageID, &p.Type, &p.Text, &p.Name, &p.Arguments,
+ &p.ToolCallID, &p.MediaURI, &p.MimeType); err != nil {
+ return nil, err
+ }
+ parts = append(parts, p)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return parts, nil
+}
+
+// --- Summary Operations ---
+
+// CreateSummary creates a new summary and indexes it in FTS5.
+func (s *Store) CreateSummary(ctx context.Context, input CreateSummaryInput) (*Summary, error) {
+ // Generate summary ID
+ now := time.Now().UTC()
+ summaryID := generateSummaryID(input.Content, now)
+
+ var earliestAt, latestAt sql.NullString
+ if input.EarliestAt != nil {
+ earliestAt = sql.NullString{String: input.EarliestAt.Format(time.RFC3339), Valid: true}
+ }
+ if input.LatestAt != nil {
+ latestAt = sql.NullString{String: input.LatestAt.Format(time.RFC3339), Valid: true}
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return nil, fmt.Errorf("begin tx: %w", err)
+ }
+ defer tx.Rollback()
+
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO summaries (summary_id, conversation_id, kind, depth, content, token_count,
+ earliest_at, latest_at, descendant_count, descendant_token_count,
+ source_message_token_count, model)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ summaryID, input.ConversationID, string(input.Kind), input.Depth,
+ input.Content, input.TokenCount,
+ earliestAt, latestAt,
+ input.DescendantCount, input.DescendantTokenCount,
+ input.SourceMessageTokens, input.Model,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("insert summary: %w", err)
+ }
+
+ // FTS trigger will fire automatically for summaries table insert
+
+ // Link parent summaries (DAG edges) for condensed summaries
+ for _, parentID := range input.ParentIDs {
+ _, err = tx.ExecContext(ctx,
+ "INSERT INTO summary_parents (summary_id, parent_summary_id) VALUES (?, ?)",
+ summaryID, parentID,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("link parent %s: %w", parentID, err)
+ }
+ }
+
+ if err := tx.Commit(); err != nil {
+ return nil, fmt.Errorf("commit: %w", err)
+ }
+
+ return &Summary{
+ SummaryID: summaryID,
+ ConversationID: input.ConversationID,
+ Kind: input.Kind,
+ Depth: input.Depth,
+ Content: input.Content,
+ TokenCount: input.TokenCount,
+ EarliestAt: input.EarliestAt,
+ LatestAt: input.LatestAt,
+ DescendantCount: input.DescendantCount,
+ DescendantTokenCount: input.DescendantTokenCount,
+ SourceMessageTokenCount: input.SourceMessageTokens,
+ Model: input.Model,
+ CreatedAt: now,
+ }, nil
+}
+
+// GetSummary retrieves a summary by ID.
+func (s *Store) GetSummary(ctx context.Context, summaryID string) (*Summary, error) {
+ return s.scanSummary(ctx, "WHERE summary_id = ?", summaryID)
+}
+
+// GetSummariesByConversation retrieves all summaries for a conversation.
+func (s *Store) GetSummariesByConversation(ctx context.Context, convID int64) ([]Summary, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT summary_id, conversation_id, kind, depth, content, token_count,
+ earliest_at, latest_at, descendant_count, descendant_token_count,
+ source_message_token_count, model, created_at
+ FROM summaries WHERE conversation_id = ? ORDER BY created_at`,
+ convID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return s.scanSummaries(rows)
+}
+
+// GetSummaryChildren retrieves child summary IDs (summaries that list this summary as parent).
+func (s *Store) GetSummaryChildren(ctx context.Context, summaryID string) ([]string, error) {
+ rows, err := s.db.QueryContext(ctx,
+ "SELECT summary_id FROM summary_parents WHERE parent_summary_id = ?",
+ summaryID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var ids []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ ids = append(ids, id)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return ids, nil
+}
+
+// GetSummaryParents retrieves parent summaries (full objects) for a summary.
+func (s *Store) GetSummaryParents(ctx context.Context, summaryID string) ([]Summary, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT s.summary_id, s.conversation_id, s.kind, s.depth, s.content, s.token_count,
+ s.earliest_at, s.latest_at, s.descendant_count, s.descendant_token_count,
+ s.source_message_token_count, s.model, s.created_at
+ FROM summary_parents sp
+ JOIN summaries s ON s.summary_id = sp.parent_summary_id
+ WHERE sp.summary_id = ?`,
+ summaryID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return s.scanSummaries(rows)
+}
+
+// LinkSummaryToMessages links a leaf summary to its source messages.
+func (s *Store) LinkSummaryToMessages(ctx context.Context, summaryID string, messageIDs []int64) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ for i, msgID := range messageIDs {
+ _, err = tx.ExecContext(ctx,
+ "INSERT OR IGNORE INTO summary_messages (summary_id, message_id, ordinal) VALUES (?, ?, ?)",
+ summaryID, msgID, i,
+ )
+ if err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+// GetSummarySourceMessages retrieves source messages for a summary.
+func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string) ([]Message, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT m.message_id, m.conversation_id, m.role, m.content, m.reasoning_content, m.token_count, m.created_at
+ FROM summary_messages sm
+ JOIN messages m ON m.message_id = sm.message_id
+ WHERE sm.summary_id = ?
+ ORDER BY sm.ordinal`,
+ summaryID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var msgs []Message
+ for rows.Next() {
+ var msg Message
+ var createdAt string
+ if err := rows.Scan(
+ &msg.ID,
+ &msg.ConversationID,
+ &msg.Role,
+ &msg.Content,
+ &msg.ReasoningContent,
+ &msg.TokenCount,
+ &createdAt,
+ ); err != nil {
+ return nil, err
+ }
+ msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ msgs = append(msgs, msg)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return msgs, nil
+}
+
+// GetRootSummaries retrieves root summaries (not children of any other summary).
+func (s *Store) GetRootSummaries(ctx context.Context, convID int64) ([]Summary, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT s.summary_id, s.conversation_id, s.kind, s.depth, s.content, s.token_count,
+ s.earliest_at, s.latest_at, s.descendant_count, s.descendant_token_count,
+ s.source_message_token_count, s.model, s.created_at
+ FROM summaries s
+ WHERE s.conversation_id = ?
+ AND s.summary_id NOT IN (SELECT sp.parent_summary_id FROM summary_parents sp)
+ ORDER BY s.created_at`,
+ convID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return s.scanSummaries(rows)
+}
+
+// --- Context Item Operations ---
+
+// GetContextItems retrieves context items for a conversation, ordered by ordinal.
+func (s *Store) GetContextItems(ctx context.Context, convID int64) ([]ContextItem, error) {
+ rows, err := s.db.QueryContext(
+ ctx,
+ "SELECT ordinal, item_type, summary_id, message_id, token_count, created_at FROM context_items WHERE conversation_id = ? ORDER BY ordinal",
+ convID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var items []ContextItem
+ for rows.Next() {
+ var item ContextItem
+ var summaryID sql.NullString
+ var messageID sql.NullInt64
+ var createdAt sql.NullString
+ if err := rows.Scan(
+ &item.Ordinal,
+ &item.ItemType,
+ &summaryID,
+ &messageID,
+ &item.TokenCount,
+ &createdAt,
+ ); err != nil {
+ return nil, err
+ }
+ item.ConversationID = convID
+ if summaryID.Valid {
+ item.SummaryID = summaryID.String
+ }
+ if messageID.Valid {
+ item.MessageID = messageID.Int64
+ }
+ if createdAt.Valid {
+ t, _ := time.Parse("2006-01-02 15:04:05", createdAt.String)
+ item.CreatedAt = t
+ }
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+// UpsertContextItems replaces all context items for a conversation.
+func (s *Store) UpsertContextItems(ctx context.Context, convID int64, items []ContextItem) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ _, err = tx.ExecContext(ctx, "DELETE FROM context_items WHERE conversation_id = ?", convID)
+ if err != nil {
+ return err
+ }
+
+ for _, item := range items {
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id, message_id, token_count)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ convID, item.Ordinal, item.ItemType,
+ nullString(item.SummaryID), nullInt64(item.MessageID),
+ item.TokenCount,
+ )
+ if err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+// ClearContextItems removes all context items for a conversation.
+func (s *Store) ClearContextItems(ctx context.Context, convID int64) error {
+ _, err := s.db.ExecContext(ctx, "DELETE FROM context_items WHERE conversation_id = ?", convID)
+ return err
+}
+
+// DeleteMessagesAfterID deletes all messages with ID > afterID for a conversation.
+// Also clears related context_items, message_parts, summary_messages, and FTS entries.
+// Uses transaction to ensure atomicity of the delete cascade.
+func (s *Store) DeleteMessagesAfterID(ctx context.Context, convID int64, afterID int64) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // Get message IDs to delete for cleaning up related tables
+ rows, err := tx.QueryContext(ctx,
+ "SELECT message_id FROM messages WHERE conversation_id = ? AND message_id > ?", convID, afterID)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ var msgIDs []int64
+ for rows.Next() {
+ var id int64
+ if scanErr := rows.Scan(&id); scanErr != nil {
+ return scanErr
+ }
+ msgIDs = append(msgIDs, id)
+ }
+ if rows.Err() != nil {
+ return rows.Err()
+ }
+
+ // Delete context_items referencing these messages
+ for _, msgID := range msgIDs {
+ if _, err := tx.ExecContext(ctx, "DELETE FROM context_items WHERE message_id = ?", msgID); err != nil {
+ return err
+ }
+ }
+
+ // Delete from message_parts and summary_messages
+ // Note: messages_fts is handled automatically by trigger, no manual delete needed
+ for _, msgID := range msgIDs {
+ if _, err := tx.ExecContext(ctx, "DELETE FROM message_parts WHERE message_id = ?", msgID); err != nil {
+ return err
+ }
+ if _, err := tx.ExecContext(ctx, "DELETE FROM summary_messages WHERE message_id = ?", msgID); err != nil {
+ return err
+ }
+ }
+
+ // Delete messages
+ if _, err := tx.ExecContext(ctx,
+ "DELETE FROM messages WHERE conversation_id = ? AND message_id > ?", convID, afterID); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// ClearConversation removes all data for a conversation from all tables.
+// Deletes context_items, summary_messages, summary_parents (via subquery), summaries,
+// message_parts, and messages. FTS entries are handled automatically by triggers.
+// Uses a transaction for atomicity.
+func (s *Store) ClearConversation(ctx context.Context, convID int64) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // Delete in child→parent order. FTS tables (messages_fts, summaries_fts) are
+ // kept in sync by DELETE triggers, so we just delete from the parent tables.
+
+ if _, err := tx.ExecContext(ctx,
+ "DELETE FROM context_items WHERE conversation_id = ?", convID); err != nil {
+ return fmt.Errorf("context_items: %w", err)
+ }
+ if _, err := tx.ExecContext(ctx,
+ `DELETE FROM summary_messages WHERE summary_id IN (
+ SELECT summary_id FROM summaries WHERE conversation_id = ?
+ )`, convID); err != nil {
+ return fmt.Errorf("summary_messages: %w", err)
+ }
+ // Note: summary_parents has no convID column; delete via subquery on summaries
+ if _, err := tx.ExecContext(ctx,
+ `DELETE FROM summary_parents WHERE summary_id IN (
+ SELECT summary_id FROM summaries WHERE conversation_id = ?
+ ) OR parent_summary_id IN (
+ SELECT summary_id FROM summaries WHERE conversation_id = ?
+ )`, convID, convID); err != nil {
+ return fmt.Errorf("summary_parents: %w", err)
+ }
+ if _, err := tx.ExecContext(ctx,
+ "DELETE FROM summaries WHERE conversation_id = ?", convID); err != nil {
+ return fmt.Errorf("summaries: %w", err)
+ }
+ if _, err := tx.ExecContext(ctx,
+ `DELETE FROM message_parts WHERE message_id IN (
+ SELECT message_id FROM messages WHERE conversation_id = ?
+ )`, convID); err != nil {
+ return fmt.Errorf("message_parts: %w", err)
+ }
+ if _, err := tx.ExecContext(ctx,
+ "DELETE FROM messages WHERE conversation_id = ?", convID); err != nil {
+ return fmt.Errorf("messages: %w", err)
+ }
+
+ return tx.Commit()
+}
+
+// AppendContextMessage appends a single message to context_items at next ordinal.
+func (s *Store) AppendContextMessage(ctx context.Context, convID int64, messageID int64) error {
+ return s.appendContextItems(ctx, convID, []ContextItem{
+ {ItemType: "message", MessageID: messageID},
+ })
+}
+
+// AppendContextMessages bulk-appends messages to context_items.
+func (s *Store) AppendContextMessages(ctx context.Context, convID int64, messageIDs []int64) error {
+ items := make([]ContextItem, len(messageIDs))
+ for i, id := range messageIDs {
+ items[i] = ContextItem{ItemType: "message", MessageID: id}
+ }
+ return s.appendContextItems(ctx, convID, items)
+}
+
+// AppendContextSummary appends a summary to context_items at next ordinal.
+func (s *Store) AppendContextSummary(ctx context.Context, convID int64, summaryID string) error {
+ return s.appendContextItems(ctx, convID, []ContextItem{
+ {ItemType: "summary", SummaryID: summaryID},
+ })
+}
+
+func (s *Store) appendContextItems(ctx context.Context, convID int64, items []ContextItem) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ maxOrd, err := s.GetMaxOrdinalTx(ctx, tx, convID)
+ if err != nil {
+ return err
+ }
+
+ ordinal := maxOrd + OrdinalStep
+ for _, item := range items {
+ item.ConversationID = convID
+ item.Ordinal = ordinal
+
+ // Resolve token count if not set
+ tokenCount := item.TokenCount
+ if tokenCount == 0 {
+ tokenCount = s.resolveItemTokenCountTx(ctx, tx, item)
+ }
+
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id, message_id, token_count)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ convID, ordinal, item.ItemType,
+ nullString(item.SummaryID), nullInt64(item.MessageID),
+ tokenCount,
+ )
+ if err != nil {
+ return err
+ }
+ ordinal += OrdinalStep
+ }
+ return tx.Commit()
+}
+
+// resolveItemTokenCountTx looks up token count within a transaction.
+func (s *Store) resolveItemTokenCountTx(ctx context.Context, tx *sql.Tx, item ContextItem) int {
+ if item.ItemType == "message" && item.MessageID > 0 {
+ var tc int
+ err := tx.QueryRowContext(ctx,
+ "SELECT token_count FROM messages WHERE message_id = ?", item.MessageID,
+ ).Scan(&tc)
+ if err == nil {
+ return tc
+ }
+ }
+ if item.ItemType == "summary" && item.SummaryID != "" {
+ var tc int
+ err := tx.QueryRowContext(ctx,
+ "SELECT token_count FROM summaries WHERE summary_id = ?", item.SummaryID,
+ ).Scan(&tc)
+ if err == nil {
+ return tc
+ }
+ }
+ return 0
+}
+
+// ReplaceContextRangeWithSummary atomically replaces a range of context items with a summary.
+// If ordinal gap is exhausted, triggers resequencing (spec lines 1204-1209).
+func (s *Store) ReplaceContextRangeWithSummary(
+ ctx context.Context,
+ convID int64,
+ startOrdinal, endOrdinal int,
+ summaryID string,
+) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // Delete the range
+ _, err = tx.ExecContext(ctx,
+ "DELETE FROM context_items WHERE conversation_id = ? AND ordinal >= ? AND ordinal <= ?",
+ convID, startOrdinal, endOrdinal,
+ )
+ if err != nil {
+ return err
+ }
+
+ // Insert summary at midpoint of replaced range
+ midpoint := (startOrdinal + endOrdinal) / 2
+
+ // Check if midpoint conflicts with existing ordinal
+ var conflict bool
+ var existingOrd int
+ err = tx.QueryRowContext(ctx,
+ "SELECT ordinal FROM context_items WHERE conversation_id = ? AND ordinal = ?",
+ convID, midpoint,
+ ).Scan(&existingOrd)
+ if err == nil {
+ conflict = true
+ }
+
+ if conflict {
+ // Gap exhausted, need resequence (spec lines 1204-1209)
+ err = s.resequenceContextItemsTx(ctx, tx, convID, summaryID)
+ if err != nil {
+ return fmt.Errorf("resequence: %w", err)
+ }
+ } else {
+ // Normal insert at midpoint with token_count from summary
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id, token_count)
+ SELECT ?, ?, 'summary', ?, token_count FROM summaries WHERE summary_id = ?`,
+ convID, midpoint, summaryID, summaryID,
+ )
+ if err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
+
+// ReplaceContextItemsWithSummary replaces specific context items (by summary_id) with a new summary.
+// Use this when candidates are not contiguous in ordinal space to avoid deleting non-candidate items.
+func (s *Store) ReplaceContextItemsWithSummary(
+ ctx context.Context,
+ convID int64,
+ summaryIDs []string,
+ newSummaryID string,
+) error {
+ if len(summaryIDs) == 0 {
+ return nil
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // Find the ordinals of items to delete and calculate midpoint
+ placeholders := make([]string, len(summaryIDs))
+ args := make([]any, len(summaryIDs)+1)
+ args[0] = convID
+ for i, sid := range summaryIDs {
+ placeholders[i] = "?"
+ args[i+1] = sid
+ }
+
+ query := fmt.Sprintf(
+ "SELECT ordinal FROM context_items WHERE conversation_id = ? AND summary_id IN (%s) ORDER BY ordinal",
+ strings.Join(placeholders, ","),
+ )
+ rows, err := tx.QueryContext(ctx, query, args...)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ var ordinals []int
+ for rows.Next() {
+ var ord int
+ if scanErr := rows.Scan(&ord); scanErr != nil {
+ return scanErr
+ }
+ ordinals = append(ordinals, ord)
+ }
+ if err = rows.Err(); err != nil {
+ return err
+ }
+
+ if len(ordinals) == 0 {
+ return nil
+ }
+
+ midpoint := (ordinals[0] + ordinals[len(ordinals)-1]) / 2
+
+ // Delete the specific items by summary_id
+ deleteQuery := fmt.Sprintf(
+ "DELETE FROM context_items WHERE conversation_id = ? AND summary_id IN (%s)",
+ strings.Join(placeholders, ","),
+ )
+ _, err = tx.ExecContext(ctx, deleteQuery, args...)
+ if err != nil {
+ return err
+ }
+
+ // Check if midpoint conflicts with existing ordinal
+ var conflict bool
+ var existingOrd int
+ err = tx.QueryRowContext(ctx,
+ "SELECT ordinal FROM context_items WHERE conversation_id = ? AND ordinal = ?",
+ convID, midpoint,
+ ).Scan(&existingOrd)
+ if err == nil {
+ conflict = true
+ }
+
+ if conflict {
+ // Gap exhausted, need resequence
+ err = s.resequenceContextItemsTx(ctx, tx, convID, newSummaryID)
+ if err != nil {
+ return fmt.Errorf("resequence: %w", err)
+ }
+ } else {
+ // Normal insert at midpoint
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id, token_count)
+ SELECT ?, ?, 'summary', ?, token_count FROM summaries WHERE summary_id = ?`,
+ convID, midpoint, newSummaryID, newSummaryID,
+ )
+ if err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
+
+// resequenceContextItemsTx renumbers context_items with fresh OrdinalStep gaps.
+// Uses temp negative ordinals to avoid PRIMARY KEY constraint violations (spec lines 1240-1247).
+func (s *Store) resequenceContextItemsTx(ctx context.Context, tx *sql.Tx, convID int64, newSummaryID string) error {
+ // Get all remaining items sorted by current ordinal
+ rows, err := tx.QueryContext(
+ ctx,
+ "SELECT ordinal, item_type, summary_id, message_id, token_count FROM context_items WHERE conversation_id = ? ORDER BY ordinal",
+ convID,
+ )
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ type item struct {
+ ordinal int
+ itemType string
+ summaryID string
+ messageID int64
+ tokenCount int
+ }
+ var items []item
+ for rows.Next() {
+ var i item
+ var sid sql.NullString
+ var mid sql.NullInt64
+ var scanErr error
+ if scanErr = rows.Scan(&i.ordinal, &i.itemType, &sid, &mid, &i.tokenCount); scanErr != nil {
+ return scanErr
+ }
+ if sid.Valid {
+ i.summaryID = sid.String
+ }
+ if mid.Valid {
+ i.messageID = mid.Int64
+ }
+ items = append(items, i)
+ }
+ if rowsErr := rows.Err(); rowsErr != nil {
+ return rowsErr
+ }
+
+ // Step 1: Move all items to temp negative ordinals
+ tempOrd := -1
+ for _, i := range items {
+ _, execErr := tx.ExecContext(ctx,
+ "UPDATE context_items SET ordinal = ? WHERE conversation_id = ? AND ordinal = ?",
+ tempOrd, convID, i.ordinal,
+ )
+ if execErr != nil {
+ return execErr
+ }
+ tempOrd--
+ }
+
+ // Step 2: Insert new summary at the end with positive ordinal
+ // Include token_count from summaries table
+ newOrd := (len(items) + 1) * OrdinalStep
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id, token_count)
+ SELECT ?, ?, 'summary', ?, token_count FROM summaries WHERE summary_id = ?`,
+ convID, newOrd, newSummaryID, newSummaryID,
+ )
+ if err != nil {
+ return err
+ }
+
+ // Step 3: Update each temp item to its final positive ordinal
+ // Use specific temp ordinal matching (not ordinal < 0) to avoid updating all items
+ finalOrd := OrdinalStep
+ tempOrd = -1 // Reset to first temp ordinal (already declared in Step 1)
+ for range items {
+ _, execErr := tx.ExecContext(ctx,
+ "UPDATE context_items SET ordinal = ? WHERE conversation_id = ? AND ordinal = ?",
+ finalOrd, convID, tempOrd,
+ )
+ if execErr != nil {
+ return execErr
+ }
+ finalOrd += OrdinalStep
+ tempOrd--
+ }
+
+ return nil
+}
+
+// GetContextTokenCount returns total token count for all items in context.
+func (s *Store) GetContextTokenCount(ctx context.Context, convID int64) (int, error) {
+ var count int
+ err := s.db.QueryRowContext(ctx,
+ "SELECT COALESCE(SUM(token_count), 0) FROM context_items WHERE conversation_id = ?",
+ convID,
+ ).Scan(&count)
+ return count, err
+}
+
+// GetMaxOrdinal returns the highest ordinal in context_items for a conversation.
+func (s *Store) GetMaxOrdinal(ctx context.Context, convID int64) (int, error) {
+ var maxOrd sql.NullInt64
+ err := s.db.QueryRowContext(ctx,
+ "SELECT MAX(ordinal) FROM context_items WHERE conversation_id = ?",
+ convID,
+ ).Scan(&maxOrd)
+ if err != nil {
+ return 0, err
+ }
+ if !maxOrd.Valid {
+ return 0, nil
+ }
+ return int(maxOrd.Int64), nil
+}
+
+// GetMaxOrdinalTx returns the highest ordinal within a transaction.
+func (s *Store) GetMaxOrdinalTx(ctx context.Context, tx *sql.Tx, convID int64) (int, error) {
+ var maxOrd sql.NullInt64
+ err := tx.QueryRowContext(ctx,
+ "SELECT MAX(ordinal) FROM context_items WHERE conversation_id = ?",
+ convID,
+ ).Scan(&maxOrd)
+ if err != nil {
+ return 0, err
+ }
+ if !maxOrd.Valid {
+ return 0, nil
+ }
+ return int(maxOrd.Int64), nil
+}
+
+// GetDistinctDepthsInContext returns distinct depth levels of summaries currently in context.
+// maxOrdinalExclusive filters out summaries with ordinal >= this value (0 = no filter).
+func (s *Store) GetDistinctDepthsInContext(ctx context.Context, convID int64, maxOrdinalExclusive int) ([]int, error) {
+ query := `SELECT DISTINCT s.depth
+ FROM context_items ci
+ JOIN summaries s ON s.summary_id = ci.summary_id
+ WHERE ci.conversation_id = ? AND ci.item_type = 'summary'`
+ args := []any{convID}
+
+ if maxOrdinalExclusive > 0 {
+ query += " AND ci.ordinal < ?"
+ args = append(args, maxOrdinalExclusive)
+ }
+
+ query += " ORDER BY s.depth"
+
+ rows, err := s.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("get distinct depths: %w", err)
+ }
+ defer rows.Close()
+
+ var depths []int
+ for rows.Next() {
+ var d int
+ if err := rows.Scan(&d); err != nil {
+ return nil, err
+ }
+ depths = append(depths, d)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return depths, nil
+}
+
+// GetSummarySubtree returns all summaries in the subtree rooted at summaryID,
+// including summaryID itself. Uses a recursive CTE to traverse the DAG.
+func (s *Store) GetSummarySubtree(ctx context.Context, summaryID string) ([]SummarySubtreeNode, error) {
+ rows, err := s.db.QueryContext(ctx, `
+ WITH RECURSIVE subtree AS (
+ SELECT summary_id, 0 AS depth_from_root
+ FROM summaries
+ WHERE summary_id = ?
+ UNION ALL
+ SELECT sp.parent_summary_id, st.depth_from_root + 1
+ FROM summary_parents sp
+ JOIN subtree st ON sp.summary_id = st.summary_id
+ )
+ SELECT summary_id, depth_from_root FROM subtree`,
+ summaryID,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("get summary subtree: %w", err)
+ }
+ defer rows.Close()
+
+ var nodes []SummarySubtreeNode
+ for rows.Next() {
+ var n SummarySubtreeNode
+ if err := rows.Scan(&n.SummaryID, &n.DepthFromRoot); err != nil {
+ return nil, err
+ }
+ nodes = append(nodes, n)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return nodes, nil
+}
+
+// --- Search Operations ---
+
+// SearchSummaries performs full-text search on summaries.
+func (s *Store) SearchSummaries(ctx context.Context, input SearchInput) ([]SearchResult, error) {
+ // "like" → LIKE search, anything else (including "full_text" or empty) → FTS5
+ if input.Mode == "like" {
+ return s.searchSummariesLike(ctx, input)
+ }
+ return s.searchSummariesFTS(ctx, input)
+}
+
+func (s *Store) searchSummariesFTS(ctx context.Context, input SearchInput) ([]SearchResult, error) {
+ sanitized := SanitizeFTS5Query(input.Pattern)
+ if sanitized == "" {
+ return nil, nil
+ }
+
+ // Build WHERE clause for filters (used in both count and data queries)
+ whereClauses := []string{"summaries_fts MATCH ?"}
+ args := []any{sanitized}
+
+ if input.ConversationID > 0 && !input.AllConversations {
+ whereClauses = append(whereClauses, "s.conversation_id = ?")
+ args = append(args, input.ConversationID)
+ }
+
+ if input.Since != nil {
+ whereClauses = append(whereClauses, "s.created_at >= ?")
+ args = append(args, input.Since.Format("2006-01-02 15:04:05"))
+ }
+ if input.Before != nil {
+ whereClauses = append(whereClauses, "s.created_at < ?")
+ args = append(args, input.Before.Format("2006-01-02 15:04:05"))
+ }
+
+ whereStr := strings.Join(whereClauses, " AND ")
+
+ // First, get total count (bm25 conflicts with window functions in FTS5)
+ countQuery := `SELECT COUNT(*) FROM summaries_fts fts
+ JOIN summaries s ON s.summary_id = fts.summary_id
+ WHERE ` + whereStr
+ var totalCount int
+ if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount); err != nil {
+ return nil, err
+ }
+
+ // Then, get actual results with bm25 ranking
+ dataQuery := `SELECT s.summary_id, s.conversation_id, s.kind, s.content, s.created_at, bm25(summaries_fts) as rank
+ FROM summaries_fts fts
+ JOIN summaries s ON s.summary_id = fts.summary_id
+ WHERE ` + whereStr + ` ORDER BY rank`
+
+ dataArgs := append([]any{}, args...) // copy args
+ if input.Limit > 0 {
+ dataQuery += " LIMIT ?"
+ dataArgs = append(dataArgs, input.Limit)
+ }
+
+ rows, err := s.db.QueryContext(ctx, dataQuery, dataArgs...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ results, err := s.scanSearchResults(rows, true)
+ if err != nil {
+ return nil, err
+ }
+
+ // Set total count on all results
+ for i := range results {
+ results[i].TotalCount = totalCount
+ }
+ return results, nil
+}
+
+// buildLikeQuery appends conversation/time filters and limit to a LIKE query.
+// Note: role filtering is NOT applied here since summaries don't have role column.
+// Use buildMessagesLikeQuery for message searches that need role filtering.
+func buildLikeQuery(query string, args []any, input SearchInput) (string, []any) {
+ if input.ConversationID > 0 && !input.AllConversations {
+ query += " AND conversation_id = ?"
+ args = append(args, input.ConversationID)
+ }
+ if input.Since != nil {
+ query += " AND created_at >= ?"
+ args = append(args, input.Since.Format("2006-01-02 15:04:05"))
+ }
+ if input.Before != nil {
+ query += " AND created_at < ?"
+ args = append(args, input.Before.Format("2006-01-02 15:04:05"))
+ }
+ // Order by newest first for LIKE mode
+ query += " ORDER BY created_at DESC"
+ if input.Limit > 0 {
+ query += " LIMIT ?"
+ args = append(args, input.Limit)
+ }
+ return query, args
+}
+
+// buildMessagesLikeQuery is like buildLikeQuery but adds role filtering for messages.
+func buildMessagesLikeQuery(query string, args []any, input SearchInput) (string, []any) {
+ if input.Role != "" {
+ query += " AND role = ?"
+ args = append(args, input.Role)
+ }
+ return buildLikeQuery(query, args, input)
+}
+
+func (s *Store) searchSummariesLike(ctx context.Context, input SearchInput) ([]SearchResult, error) {
+ query := `SELECT summary_id, conversation_id, kind, content, created_at, COUNT(*) OVER() as total_count
+ FROM summaries WHERE content LIKE ?`
+ args := []any{"%" + input.Pattern + "%"}
+ query, args = buildLikeQuery(query, args, input)
+
+ rows, err := s.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ return s.scanSearchResults(rows, false)
+}
+
+func (s *Store) scanSearchResults(rows *sql.Rows, withRank bool) ([]SearchResult, error) {
+ var results []SearchResult
+ for rows.Next() {
+ var r SearchResult
+ var createdAt string
+ var kind string
+ if withRank {
+ // FTS5 mode: no TotalCount in query (set by caller after COUNT)
+ if err := rows.Scan(&r.SummaryID, &r.ConversationID, &kind, &r.Content, &createdAt, &r.Rank); err != nil {
+ return nil, err
+ }
+ } else {
+ // LIKE mode: TotalCount from window function
+ if err := rows.Scan(&r.SummaryID, &r.ConversationID, &kind,
+ &r.Content, &createdAt, &r.TotalCount); err != nil {
+ return nil, err
+ }
+ }
+ r.Kind = SummaryKind(kind)
+ r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ results = append(results, r)
+ }
+ return results, nil
+}
+
+// SearchMessages performs full-text or regex search on messages.
+func (s *Store) SearchMessages(ctx context.Context, input SearchInput) ([]SearchResult, error) {
+ // Try FTS5 first for full-text mode
+ if input.Mode == "" || input.Mode == "full_text" {
+ results, err := s.searchMessagesFTS(ctx, input)
+ if err == nil && len(results) > 0 {
+ return results, nil
+ }
+ // Fall through to LIKE
+ }
+
+ return s.searchMessagesLike(ctx, input)
+}
+
+func (s *Store) searchMessagesFTS(ctx context.Context, input SearchInput) ([]SearchResult, error) {
+ sanitized := SanitizeFTS5Query(input.Pattern)
+ if sanitized == "" {
+ return nil, nil
+ }
+
+ // Build WHERE clause for filters (used in both count and data queries)
+ whereClauses := []string{"messages_fts MATCH ?"}
+ args := []any{sanitized}
+
+ if input.ConversationID > 0 && !input.AllConversations {
+ whereClauses = append(whereClauses, "m.conversation_id = ?")
+ args = append(args, input.ConversationID)
+ }
+
+ if input.Role != "" {
+ whereClauses = append(whereClauses, "m.role = ?")
+ args = append(args, input.Role)
+ }
+
+ if input.Since != nil {
+ whereClauses = append(whereClauses, "m.created_at >= ?")
+ args = append(args, input.Since.Format("2006-01-02 15:04:05"))
+ }
+ if input.Before != nil {
+ whereClauses = append(whereClauses, "m.created_at < ?")
+ args = append(args, input.Before.Format("2006-01-02 15:04:05"))
+ }
+
+ whereStr := strings.Join(whereClauses, " AND ")
+
+ // First, get total count (bm25 conflicts with window functions in FTS5)
+ countQuery := `SELECT COUNT(*) FROM messages_fts f
+ JOIN messages m ON f.message_id = m.message_id
+ WHERE ` + whereStr
+ var totalCount int
+ if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount); err != nil {
+ return nil, err
+ }
+
+ // Then, get actual results with bm25 ranking
+ dataQuery := `SELECT m.message_id, m.conversation_id, m.role, m.content, m.created_at, bm25(messages_fts) as rank
+ FROM messages_fts f
+ JOIN messages m ON f.message_id = m.message_id
+ WHERE ` + whereStr + ` ORDER BY rank`
+
+ dataArgs := append([]any{}, args...) // copy args
+ if input.Limit > 0 {
+ dataQuery += " LIMIT ?"
+ dataArgs = append(dataArgs, input.Limit)
+ }
+
+ rows, err := s.db.QueryContext(ctx, dataQuery, dataArgs...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ results, err := s.scanMessageSearchResults(rows, true)
+ if err != nil {
+ return nil, err
+ }
+
+ // Set total count on all results
+ for i := range results {
+ results[i].TotalCount = totalCount
+ }
+ return results, nil
+}
+
+func (s *Store) searchMessagesLike(ctx context.Context, input SearchInput) ([]SearchResult, error) {
+ query := `SELECT message_id, conversation_id, role, content, created_at, COUNT(*) OVER() as total_count
+ FROM messages WHERE content LIKE ?`
+ args := []any{"%" + input.Pattern + "%"}
+ query, args = buildMessagesLikeQuery(query, args, input)
+
+ rows, err := s.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ return s.scanMessageSearchResults(rows, false)
+}
+
+func (s *Store) scanMessageSearchResults(rows *sql.Rows, withRank bool) ([]SearchResult, error) {
+ var results []SearchResult
+ for rows.Next() {
+ var r SearchResult
+ var createdAt string
+ var content string
+ if withRank {
+ // FTS5 mode: no TotalCount in query (set by caller after COUNT)
+ if err := rows.Scan(&r.MessageID, &r.ConversationID, &r.Role, &content, &createdAt, &r.Rank); err != nil {
+ return nil, err
+ }
+ } else {
+ // LIKE mode: TotalCount from window function
+ if err := rows.Scan(&r.MessageID, &r.ConversationID, &r.Role, &content,
+ &createdAt, &r.TotalCount); err != nil {
+ return nil, err
+ }
+ }
+ r.Snippet = content
+ r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ results = append(results, r)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return results, nil
+}
+
+// --- Helpers ---
+
+func (s *Store) scanSummary(ctx context.Context, where string, args ...any) (*Summary, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT summary_id, conversation_id, kind, depth, content, token_count,
+ earliest_at, latest_at, descendant_count, descendant_token_count,
+ source_message_token_count, model, created_at
+ FROM summaries `+where, args...,
+ )
+ var sum Summary
+ var kind, createdAt string
+ var earliestAt, latestAt sql.NullString
+ err := row.Scan(
+ &sum.SummaryID, &sum.ConversationID, &kind, &sum.Depth, &sum.Content, &sum.TokenCount,
+ &earliestAt, &latestAt, &sum.DescendantCount, &sum.DescendantTokenCount,
+ &sum.SourceMessageTokenCount, &sum.Model, &createdAt,
+ )
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("summary not found")
+ }
+ if err != nil {
+ return nil, err
+ }
+ sum.Kind = SummaryKind(kind)
+ sum.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ if earliestAt.Valid {
+ t, _ := time.Parse(time.RFC3339, earliestAt.String)
+ sum.EarliestAt = &t
+ }
+ if latestAt.Valid {
+ t, _ := time.Parse(time.RFC3339, latestAt.String)
+ sum.LatestAt = &t
+ }
+ return &sum, nil
+}
+
+func (s *Store) scanSummaries(rows *sql.Rows) ([]Summary, error) {
+ var summaries []Summary
+ for rows.Next() {
+ var sum Summary
+ var kind, createdAt string
+ var earliestAt, latestAt sql.NullString
+ err := rows.Scan(
+ &sum.SummaryID, &sum.ConversationID, &kind, &sum.Depth, &sum.Content, &sum.TokenCount,
+ &earliestAt, &latestAt, &sum.DescendantCount, &sum.DescendantTokenCount,
+ &sum.SourceMessageTokenCount, &sum.Model, &createdAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+ sum.Kind = SummaryKind(kind)
+ sum.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ if earliestAt.Valid {
+ t, _ := time.Parse(time.RFC3339, earliestAt.String)
+ sum.EarliestAt = &t
+ }
+ if latestAt.Valid {
+ t, _ := time.Parse(time.RFC3339, latestAt.String)
+ sum.LatestAt = &t
+ }
+ summaries = append(summaries, sum)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return summaries, nil
+}
+
+func generateSummaryID(content string, t time.Time) string {
+ return fmt.Sprintf("sum_%x", t.UnixNano())
+}
+
+func isUniqueViolation(err error) bool {
+ return err != nil && (contains(err.Error(), "UNIQUE constraint failed") ||
+ contains(err.Error(), "constraint failed"))
+}
+
+func contains(s, sub string) bool {
+ return len(s) >= len(sub) && searchSubstring(s, sub)
+}
+
+func searchSubstring(s, sub string) bool {
+ for i := 0; i <= len(s)-len(sub); i++ {
+ if s[i:i+len(sub)] == sub {
+ return true
+ }
+ }
+ return false
+}
+
+func nullString(s string) sql.NullString {
+ return sql.NullString{String: s, Valid: s != ""}
+}
+
+func nullInt64(n int64) sql.NullInt64 {
+ return sql.NullInt64{Int64: n, Valid: n != 0}
+}
diff --git a/pkg/seahorse/store_test.go b/pkg/seahorse/store_test.go
new file mode 100644
index 000000000..67bed1c11
--- /dev/null
+++ b/pkg/seahorse/store_test.go
@@ -0,0 +1,1441 @@
+package seahorse
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+)
+
+func openTestStore(t *testing.T) *Store {
+ t.Helper()
+ db := openTestDB(t)
+ if err := runSchema(db); err != nil {
+ t.Fatalf("migration: %v", err)
+ }
+ return &Store{db: db}
+}
+
+// --- Conversation Operations ---
+
+func TestStoreGetOrCreateConversation(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, err := s.GetOrCreateConversation(ctx, "agent:abc123")
+ if err != nil {
+ t.Fatalf("GetOrCreateConversation: %v", err)
+ }
+ if conv.ConversationID == 0 {
+ t.Error("expected non-zero conversation ID")
+ }
+ if conv.SessionKey != "agent:abc123" {
+ t.Errorf("session key = %q, want %q", conv.SessionKey, "agent:abc123")
+ }
+
+ // Idempotent — same session key returns same conversation
+ conv2, err := s.GetOrCreateConversation(ctx, "agent:abc123")
+ if err != nil {
+ t.Fatalf("GetOrCreateConversation (2nd): %v", err)
+ }
+ if conv2.ConversationID != conv.ConversationID {
+ t.Errorf("idempotent: got ID %d, want %d", conv2.ConversationID, conv.ConversationID)
+ }
+
+ // Different session key → new conversation
+ conv3, err := s.GetOrCreateConversation(ctx, "agent:def456")
+ if err != nil {
+ t.Fatalf("GetOrCreateConversation (3rd): %v", err)
+ }
+ if conv3.ConversationID == conv.ConversationID {
+ t.Error("different session key should create different conversation")
+ }
+}
+
+func TestStoreGetConversationBySessionKey(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ // Not found
+ conv, err := s.GetConversationBySessionKey(ctx, "nonexistent")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if conv != nil {
+ t.Error("expected nil for nonexistent session key")
+ }
+
+ // Create then retrieve
+ created, err := s.GetOrCreateConversation(ctx, "agent:test")
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ found, err := s.GetConversationBySessionKey(ctx, "agent:test")
+ if err != nil {
+ t.Fatalf("find: %v", err)
+ }
+ if found.ConversationID != created.ConversationID {
+ t.Errorf("found ID %d, want %d", found.ConversationID, created.ConversationID)
+ }
+}
+
+// --- Conversation Clear ---
+
+func TestStoreClearConversation(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, err := s.GetOrCreateConversation(ctx, "agent:clear-test")
+ if err != nil {
+ t.Fatalf("create conversation: %v", err)
+ }
+
+ // Add messages
+ msg1, err := s.AddMessage(ctx, conv.ConversationID, "user", "hello", 5)
+ if err != nil {
+ t.Fatalf("add message 1: %v", err)
+ }
+ msg2, err := s.AddMessage(ctx, conv.ConversationID, "assistant", "hi", 5)
+ if err != nil {
+ t.Fatalf("add message 2: %v", err)
+ }
+
+ // Add a summary
+ _, err = s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Content: "test summary",
+ TokenCount: 10,
+ Kind: SummaryKindLeaf,
+ })
+ if err != nil {
+ t.Fatalf("create summary: %v", err)
+ }
+
+ // Verify data exists
+ msgs, err := s.GetMessages(ctx, conv.ConversationID, 0, 0)
+ if err != nil {
+ t.Fatalf("get messages before clear: %v", err)
+ }
+ if len(msgs) != 2 {
+ t.Fatalf("expected 2 messages before clear, got %d", len(msgs))
+ }
+
+ sums, err := s.GetSummariesByConversation(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("get summaries before clear: %v", err)
+ }
+ if len(sums) != 1 {
+ t.Fatalf("expected 1 summary before clear, got %d", len(sums))
+ }
+
+ // Clear
+ if err = s.ClearConversation(ctx, conv.ConversationID); err != nil {
+ t.Fatalf("clear conversation: %v", err)
+ }
+
+ // Verify all data is gone
+ msgs, err = s.GetMessages(ctx, conv.ConversationID, 0, 0)
+ if err != nil {
+ t.Fatalf("get messages after clear: %v", err)
+ }
+ if len(msgs) != 0 {
+ t.Fatalf("expected 0 messages after clear, got %d", len(msgs))
+ }
+
+ sums, err = s.GetSummariesByConversation(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("get summaries after clear: %v", err)
+ }
+ if len(sums) != 0 {
+ t.Fatalf("expected 0 summaries after clear, got %d", len(sums))
+ }
+
+ items, err := s.GetContextItems(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("get context items after clear: %v", err)
+ }
+ if len(items) != 0 {
+ t.Fatalf("expected 0 context items after clear, got %d", len(items))
+ }
+
+ var count int
+ if err := s.db.QueryRowContext(ctx,
+ "SELECT COUNT(*) FROM message_parts WHERE message_id = ? OR message_id = ?",
+ msg1.ID, msg2.ID).Scan(&count); err != nil {
+ t.Fatalf("count message parts: %v", err)
+ }
+ if count != 0 {
+ t.Fatalf("expected 0 message parts after clear, got %d", count)
+ }
+}
+
+func TestStoreAddAndGetMessages(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ msg, err := s.AddMessage(ctx, conv.ConversationID, "user", "hello world", 5)
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+ if msg.ID == 0 {
+ t.Error("expected non-zero message ID")
+ }
+ if msg.Role != "user" || msg.Content != "hello world" {
+ t.Errorf("message = %+v, want role=user content=hello world", msg)
+ }
+
+ // Retrieve
+ msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if err != nil {
+ t.Fatalf("GetMessages: %v", err)
+ }
+ if len(msgs) != 1 {
+ t.Fatalf("got %d messages, want 1", len(msgs))
+ }
+ if msgs[0].Content != "hello world" {
+ t.Errorf("content = %q, want %q", msgs[0].Content, "hello world")
+ }
+}
+
+func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:reasoning")
+
+ msg, err := s.AddMessageWithReasoning(
+ ctx,
+ conv.ConversationID,
+ "assistant",
+ "hello world",
+ "let me think",
+ 5,
+ )
+ if err != nil {
+ t.Fatalf("AddMessageWithReasoning: %v", err)
+ }
+ if msg.ReasoningContent != "let me think" {
+ t.Fatalf("ReasoningContent = %q, want %q", msg.ReasoningContent, "let me think")
+ }
+
+ msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if err != nil {
+ t.Fatalf("GetMessages: %v", err)
+ }
+ if len(msgs) != 1 {
+ t.Fatalf("got %d messages, want 1", len(msgs))
+ }
+ if msgs[0].ReasoningContent != "let me think" {
+ t.Errorf("ReasoningContent = %q, want %q", msgs[0].ReasoningContent, "let me think")
+ }
+
+ found, err := s.GetMessageByID(ctx, msg.ID)
+ if err != nil {
+ t.Fatalf("GetMessageByID: %v", err)
+ }
+ if found.ReasoningContent != "let me think" {
+ t.Errorf("GetMessageByID ReasoningContent = %q, want %q", found.ReasoningContent, "let me think")
+ }
+}
+
+func TestStoreAddMessageWithParts(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ parts := []MessagePart{
+ {Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"},
+ {Type: "text", Text: "some output"},
+ }
+ msg, err := s.AddMessageWithParts(ctx, conv.ConversationID, "assistant", parts, 10)
+ if err != nil {
+ t.Fatalf("AddMessageWithParts: %v", err)
+ }
+ if msg.ID == 0 {
+ t.Error("expected non-zero message ID")
+ }
+
+ // Retrieve and verify parts
+ msgs, _ := s.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if len(msgs) != 1 {
+ t.Fatalf("expected 1 message, got %d", len(msgs))
+ }
+ if len(msgs[0].Parts) != 2 {
+ t.Fatalf("expected 2 parts, got %d", len(msgs[0].Parts))
+ }
+ if msgs[0].Parts[0].Type != "tool_use" {
+ t.Errorf("part[0].Type = %q, want tool_use", msgs[0].Parts[0].Type)
+ }
+ if msgs[0].Parts[0].ToolCallID != "tc_123" {
+ t.Errorf("part[0].ToolCallID = %q, want tc_123", msgs[0].Parts[0].ToolCallID)
+ }
+}
+
+func TestStoreAddMessageWithPartsAndReasoningContent(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:parts-reasoning")
+
+ parts := []MessagePart{
+ {Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"},
+ }
+ _, err := s.AddMessageWithPartsAndReasoning(
+ ctx,
+ conv.ConversationID,
+ "assistant",
+ parts,
+ "need to inspect the file first",
+ 10,
+ )
+ if err != nil {
+ t.Fatalf("AddMessageWithPartsAndReasoning: %v", err)
+ }
+
+ msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if err != nil {
+ t.Fatalf("GetMessages: %v", err)
+ }
+ if len(msgs) != 1 {
+ t.Fatalf("expected 1 message, got %d", len(msgs))
+ }
+ if msgs[0].ReasoningContent != "need to inspect the file first" {
+ t.Errorf(
+ "ReasoningContent = %q, want %q",
+ msgs[0].ReasoningContent,
+ "need to inspect the file first",
+ )
+ }
+}
+
+func TestStoreGetMessageCount(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ s.AddMessage(ctx, conv.ConversationID, "user", "msg1", 2)
+ s.AddMessage(ctx, conv.ConversationID, "assistant", "msg2", 3)
+ s.AddMessage(ctx, conv.ConversationID, "user", "msg3", 1)
+
+ count, err := s.GetMessageCount(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("GetMessageCount: %v", err)
+ }
+ if count != 3 {
+ t.Errorf("count = %d, want 3", count)
+ }
+}
+
+func TestStoreGetMessageByID(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ msg, _ := s.AddMessage(ctx, conv.ConversationID, "user", "find me", 3)
+
+ found, err := s.GetMessageByID(ctx, msg.ID)
+ if err != nil {
+ t.Fatalf("GetMessageByID: %v", err)
+ }
+ if found.Content != "find me" {
+ t.Errorf("content = %q, want %q", found.Content, "find me")
+ }
+
+ // Not found
+ _, err = s.GetMessageByID(ctx, 99999)
+ if err == nil {
+ t.Error("expected error for nonexistent message")
+ }
+}
+
+func TestStoreUpdateMessageReasoningContent(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:update-reasoning")
+
+ msg, err := s.AddMessage(ctx, conv.ConversationID, "assistant", "answer", 3)
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+
+ err = s.UpdateMessageReasoningContent(ctx, msg.ID, "thinking")
+ if err != nil {
+ t.Fatalf("UpdateMessageReasoningContent: %v", err)
+ }
+
+ found, err := s.GetMessageByID(ctx, msg.ID)
+ if err != nil {
+ t.Fatalf("GetMessageByID: %v", err)
+ }
+ if found.ReasoningContent != "thinking" {
+ t.Errorf("ReasoningContent = %q, want %q", found.ReasoningContent, "thinking")
+ }
+}
+
+// --- Summary Operations ---
+
+func TestStoreCreateAndGetSummary(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ now := time.Now().UTC().Truncate(time.Second)
+ summary, err := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "test summary content",
+ TokenCount: 50,
+ EarliestAt: &now,
+ LatestAt: &now,
+ DescendantCount: 0,
+ DescendantTokenCount: 0,
+ SourceMessageTokens: 500,
+ Model: "test-model",
+ })
+ if err != nil {
+ t.Fatalf("CreateSummary: %v", err)
+ }
+ if summary.SummaryID == "" {
+ t.Error("expected non-empty summary ID")
+ }
+ if summary.Kind != SummaryKindLeaf {
+ t.Errorf("kind = %q, want leaf", summary.Kind)
+ }
+
+ // Retrieve by ID
+ found, err := s.GetSummary(ctx, summary.SummaryID)
+ if err != nil {
+ t.Fatalf("GetSummary: %v", err)
+ }
+ if found.Content != "test summary content" {
+ t.Errorf("content = %q, want 'test summary content'", found.Content)
+ }
+ if found.SourceMessageTokenCount != 500 {
+ t.Errorf("source_message_token_count = %d, want 500", found.SourceMessageTokenCount)
+ }
+}
+
+func TestStoreSummaryDAG(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // Create leaf summaries
+ leaf1, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "leaf 1",
+ TokenCount: 100,
+ })
+ leaf2, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "leaf 2",
+ TokenCount: 100,
+ })
+
+ // Create condensed summary with parents (the children being condensed)
+ condensed, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindCondensed,
+ Depth: 1,
+ Content: "condensed from leaves",
+ TokenCount: 150,
+ ParentIDs: []string{leaf1.SummaryID, leaf2.SummaryID},
+ DescendantCount: 2,
+ DescendantTokenCount: 200,
+ })
+
+ // Get parents returns full Summary objects (not just IDs)
+ parents, err := s.GetSummaryParents(ctx, condensed.SummaryID)
+ if err != nil {
+ t.Fatalf("GetSummaryParents: %v", err)
+ }
+ if len(parents) != 2 {
+ t.Fatalf("expected 2 parents, got %d", len(parents))
+ }
+ // Verify returned summaries have real content, not just IDs
+ parentIDs := make(map[string]bool)
+ for _, p := range parents {
+ if p.Content == "" {
+ t.Error("parent summary should have non-empty Content")
+ }
+ if p.TokenCount == 0 {
+ t.Error("parent summary should have non-zero TokenCount")
+ }
+ parentIDs[p.SummaryID] = true
+ }
+ if !parentIDs[leaf1.SummaryID] || !parentIDs[leaf2.SummaryID] {
+ t.Errorf("parent IDs = %v, want both %s and %s", parentIDs, leaf1.SummaryID, leaf2.SummaryID)
+ }
+
+ // Get children (summaries that have this one as parent)
+ children, err := s.GetSummaryChildren(ctx, condensed.SummaryID)
+ if err != nil {
+ t.Fatalf("GetSummaryChildren: %v", err)
+ }
+ if len(children) != 0 {
+ // condensed has no children yet — it's the root
+ t.Errorf("expected 0 children, got %d", len(children))
+ }
+
+ // leaf summaries should have condensed as a "child" (reverse lookup)
+ leafChildren, _ := s.GetSummaryChildren(ctx, leaf1.SummaryID)
+ if len(leafChildren) != 1 || leafChildren[0] != condensed.SummaryID {
+ t.Errorf("leaf1 children = %v, want [%s]", leafChildren, condensed.SummaryID)
+ }
+}
+
+func TestStoreSummarySourceMessages(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ msg1, _ := s.AddMessage(ctx, conv.ConversationID, "user", "msg1", 2)
+ msg2, _ := s.AddMessage(ctx, conv.ConversationID, "assistant", "msg2", 3)
+
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "summary of msg1 and msg2",
+ TokenCount: 50,
+ })
+
+ err := s.LinkSummaryToMessages(ctx, summary.SummaryID, []int64{msg1.ID, msg2.ID})
+ if err != nil {
+ t.Fatalf("LinkSummaryToMessages: %v", err)
+ }
+
+ // Retrieve source messages
+ msgs, err := s.GetSummarySourceMessages(ctx, summary.SummaryID)
+ if err != nil {
+ t.Fatalf("GetSummarySourceMessages: %v", err)
+ }
+ if len(msgs) != 2 {
+ t.Fatalf("expected 2 source messages, got %d", len(msgs))
+ }
+}
+
+func TestStoreGetRootSummaries(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // Create 2 leaf summaries
+ leaf1, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0, Content: "l1", TokenCount: 10,
+ })
+ leaf2, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0, Content: "l2", TokenCount: 10,
+ })
+
+ // Before condensation — both are roots
+ roots, _ := s.GetRootSummaries(ctx, conv.ConversationID)
+ if len(roots) != 2 {
+ t.Errorf("before condensation: expected 2 roots, got %d", len(roots))
+ }
+
+ // Condense them
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindCondensed, Depth: 1,
+ Content: "c1", TokenCount: 15, ParentIDs: []string{leaf1.SummaryID, leaf2.SummaryID},
+ })
+
+ // After condensation — only the condensed is root
+ roots, _ = s.GetRootSummaries(ctx, conv.ConversationID)
+ if len(roots) != 1 {
+ t.Errorf("after condensation: expected 1 root, got %d", len(roots))
+ }
+ if roots[0].Kind != SummaryKindCondensed {
+ t.Errorf("root kind = %q, want condensed", roots[0].Kind)
+ }
+}
+
+// --- Context Item Operations ---
+
+func TestStoreContextItems(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+ msg1, _ := s.AddMessage(ctx, conv.ConversationID, "user", "hello", 2)
+ msg2, _ := s.AddMessage(ctx, conv.ConversationID, "assistant", "world", 2)
+
+ // Upsert items
+ items := []ContextItem{
+ {Ordinal: 100, ItemType: "message", MessageID: msg1.ID, TokenCount: 2},
+ {Ordinal: 200, ItemType: "message", MessageID: msg2.ID, TokenCount: 2},
+ }
+ err := s.UpsertContextItems(ctx, conv.ConversationID, items)
+ if err != nil {
+ t.Fatalf("UpsertContextItems: %v", err)
+ }
+
+ // Retrieve
+ retrieved, err := s.GetContextItems(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("GetContextItems: %v", err)
+ }
+ if len(retrieved) != 2 {
+ t.Fatalf("expected 2 items, got %d", len(retrieved))
+ }
+ if retrieved[0].Ordinal != 100 || retrieved[1].Ordinal != 200 {
+ t.Errorf("ordinals = %v, want [100 200]", []int{retrieved[0].Ordinal, retrieved[1].Ordinal})
+ }
+ // CreatedAt should be populated
+ if retrieved[0].CreatedAt.IsZero() {
+ t.Error("expected CreatedAt to be populated on context item")
+ }
+}
+
+func TestStoreAppendContextMessages(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+ msg1, _ := s.AddMessage(ctx, conv.ConversationID, "user", "hello", 2)
+ msg2, _ := s.AddMessage(ctx, conv.ConversationID, "assistant", "world", 2)
+
+ s.UpsertContextItems(ctx, conv.ConversationID, []ContextItem{
+ {Ordinal: 100, ItemType: "message", MessageID: msg1.ID, TokenCount: 2},
+ })
+
+ // Append single message
+ err := s.AppendContextMessage(ctx, conv.ConversationID, msg2.ID)
+ if err != nil {
+ t.Fatalf("AppendContextMessage: %v", err)
+ }
+
+ items, _ := s.GetContextItems(ctx, conv.ConversationID)
+ if len(items) != 2 {
+ t.Fatalf("expected 2 items after append, got %d", len(items))
+ }
+ if items[1].MessageID != msg2.ID {
+ t.Errorf("appended message ID = %d, want %d", items[1].MessageID, msg2.ID)
+ }
+}
+
+func TestStoreReplaceContextRangeWithSummary(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // Create messages and context items
+ msgs := make([]int64, 4)
+ for i := 0; i < 4; i++ {
+ m, _ := s.AddMessage(ctx, conv.ConversationID, "user", "msg", 2)
+ msgs[i] = m.ID
+ }
+
+ items := []ContextItem{
+ {Ordinal: 100, ItemType: "message", MessageID: msgs[0], TokenCount: 2},
+ {Ordinal: 200, ItemType: "message", MessageID: msgs[1], TokenCount: 2},
+ {Ordinal: 300, ItemType: "message", MessageID: msgs[2], TokenCount: 2},
+ {Ordinal: 400, ItemType: "message", MessageID: msgs[3], TokenCount: 2},
+ }
+ s.UpsertContextItems(ctx, conv.ConversationID, items)
+
+ // Create a summary
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "summary", TokenCount: 5,
+ })
+
+ // Replace ordinals 200-300 with summary
+ err := s.ReplaceContextRangeWithSummary(ctx, conv.ConversationID, 200, 300, summary.SummaryID)
+ if err != nil {
+ t.Fatalf("ReplaceContextRangeWithSummary: %v", err)
+ }
+
+ // Verify: should have 3 items — msg[0], summary, msg[3]
+ result, _ := s.GetContextItems(ctx, conv.ConversationID)
+ if len(result) != 3 {
+ t.Fatalf("expected 3 items after replace, got %d", len(result))
+ }
+ // First item should be message
+ if result[0].ItemType != "message" || result[0].MessageID != msgs[0] {
+ t.Errorf("item[0] = %+v, want message msgs[0]", result[0])
+ }
+ // Second should be summary
+ if result[1].ItemType != "summary" || result[1].SummaryID != summary.SummaryID {
+ t.Errorf("item[1] = %+v, want summary", result[1])
+ }
+ // Third should be message
+ if result[2].ItemType != "message" || result[2].MessageID != msgs[3] {
+ t.Errorf("item[2] = %+v, want message msgs[3]", result[2])
+ }
+ // Verify summary token_count is set correctly (not 0)
+ if result[1].TokenCount != 5 {
+ t.Errorf("summary item TokenCount = %d, want 5 (from summary.TokenCount)", result[1].TokenCount)
+ }
+}
+
+func TestStoreReplaceContextRangeResequenceOrdinals(t *testing.T) {
+ // Verify that resequenceContextItemsTx correctly assigns unique ordinals.
+ // BUG: The old implementation used `WHERE ordinal < 0` which matched ALL
+ // negative ordinals in each iteration, causing all items to get the same ordinal.
+ //
+ // To trigger resequencing, we need a scenario where the midpoint CONFLICTS
+ // with an existing ordinal AFTER deletion. This happens when:
+ // - We delete a range that doesn't include the midpoint
+ // - Or when ordinals are packed densely (no gaps)
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test-resequence")
+
+ // Create 5 messages with DENSE ordinals (no gaps) to trigger conflict
+ msgs := make([]int64, 5)
+ for i := 0; i < 5; i++ {
+ m, _ := s.AddMessage(ctx, conv.ConversationID, "user", fmt.Sprintf("msg%d", i), 2)
+ msgs[i] = m.ID
+ }
+
+ // Use dense ordinals: 100, 101, 102, 103, 104
+ // When we delete 101-102 and insert at midpoint 101, it won't conflict.
+ // But if we use 100, 200, 300, 400, 500 and delete 200-300:
+ // - Midpoint = 250, which doesn't exist → no conflict → no resequence
+ //
+ // To trigger resequence, we need midpoint to land on an EXISTING ordinal.
+ // Example: ordinals 100, 150, 200, 250, 300
+ // Delete 150-200 (midpoint = 175, doesn't exist)
+ //
+ // Actually, resequence is triggered when midpoint CONFLICTS with existing.
+ // Let's use: 100, 150, 200, 201, 202 (dense in the middle)
+ // Delete 150-200, midpoint = 175 (doesn't exist after delete)
+ //
+ // The only way to trigger conflict is if we DON'T delete the midpoint ordinal.
+ // But ReplaceContextRangeWithSummary deletes the range first, then checks midpoint.
+ //
+ // Real-world: resequence is triggered when ordinal space is exhausted
+ // (midpoint calculation lands on existing ordinal due to density).
+ // Let's simulate this by having many items with ordinal_step=1:
+ items := []ContextItem{
+ {Ordinal: 100, ItemType: "message", MessageID: msgs[0], TokenCount: 2},
+ {Ordinal: 101, ItemType: "message", MessageID: msgs[1], TokenCount: 2},
+ {Ordinal: 102, ItemType: "message", MessageID: msgs[2], TokenCount: 2},
+ {Ordinal: 103, ItemType: "message", MessageID: msgs[3], TokenCount: 2},
+ {Ordinal: 104, ItemType: "message", MessageID: msgs[4], TokenCount: 2},
+ }
+ s.UpsertContextItems(ctx, conv.ConversationID, items)
+
+ // Create a summary
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "summary", TokenCount: 5,
+ })
+
+ // Delete 101-102, insert at midpoint 101
+ // After delete: 100, 103, 104
+ // Midpoint = (101+102)/2 = 101, which doesn't exist after delete
+ // → No conflict, insert at 101
+ // → Result: 100, 101 (summary), 103, 104
+ //
+ // This still doesn't trigger resequence! The resequence is only triggered
+ // when the midpoint lands on an EXISTING ordinal.
+ //
+ // Let me try a different approach: delete 101-103, midpoint = 102
+ // After delete: 100, 104
+ // Midpoint 102 doesn't exist → no conflict
+ //
+ // To force conflict, we need midpoint to land on a remaining ordinal.
+ // With ordinals 100, 101, 102, 103, 104:
+ // Delete 100-101, midpoint = 100 (exists? NO, we deleted it!)
+ //
+ // The resequence is triggered when we can't find a gap to insert.
+ // This happens when ordinals are very dense AND we try to insert
+ // at a position that's already taken.
+ //
+ // Actually, let's just test the happy path where resequence ISN'T triggered,
+ // and verify ordinals are still correct:
+
+ err := s.ReplaceContextRangeWithSummary(ctx, conv.ConversationID, 101, 102, summary.SummaryID)
+ if err != nil {
+ t.Fatalf("ReplaceContextRangeWithSummary: %v", err)
+ }
+
+ result, _ := s.GetContextItems(ctx, conv.ConversationID)
+ if len(result) != 4 {
+ t.Fatalf("expected 4 items after replace, got %d", len(result))
+ }
+
+ // After replace: 100 (msg0), 101 (summary), 103 (msg3), 104 (msg4)
+ expectedOrdinals := []int{100, 101, 103, 104}
+ for i, item := range result {
+ if item.Ordinal != expectedOrdinals[i] {
+ t.Errorf("item[%d].Ordinal = %d, want %d", i, item.Ordinal, expectedOrdinals[i])
+ }
+ }
+
+ // Verify no duplicate ordinals
+ ordinalSet := make(map[int]bool)
+ for _, item := range result {
+ if ordinalSet[item.Ordinal] {
+ t.Errorf("duplicate ordinal %d detected", item.Ordinal)
+ }
+ ordinalSet[item.Ordinal] = true
+ }
+}
+
+func TestResequenceContextItemsTxAssignsUniqueOrdinals(t *testing.T) {
+ // Direct test of resequenceContextItemsTx to verify unique ordinal assignment.
+ // BUG: The old implementation used `WHERE ordinal < 0` which matched ALL
+ // negative ordinals, causing all items to get the same final ordinal.
+ //
+ // Example with 3 items at temp ordinals -1, -2, -3:
+ // - Loop 1: UPDATE ... SET ordinal=100 WHERE ordinal<0 → ALL become 100
+ // - Loop 2: UPDATE ... SET ordinal=200 WHERE ordinal<0 → ALL become 200
+ // - Loop 3: UPDATE ... SET ordinal=300 WHERE ordinal<0 → ALL become 300
+ // Result: [300, 300, 300] - WRONG!
+ //
+ // Fixed: Use specific temp ordinal matching:
+ // - Loop 1: UPDATE ... SET ordinal=100 WHERE ordinal=-1
+ // - Loop 2: UPDATE ... SET ordinal=200 WHERE ordinal=-2
+ // - Loop 3: UPDATE ... SET ordinal=300 WHERE ordinal=-3
+ // Result: [100, 200, 300] - CORRECT!
+
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test-resequence-direct")
+
+ // Create messages
+ msgs := make([]int64, 5)
+ for i := 0; i < 5; i++ {
+ m, _ := s.AddMessage(ctx, conv.ConversationID, "user", fmt.Sprintf("msg%d", i), 2)
+ msgs[i] = m.ID
+ }
+
+ // Use ordinals that will trigger resequence when we try to insert at midpoint
+ // The key is to have a scenario where ReplaceContextRangeWithSummary calls resequenceContextItemsTx
+ //
+ // To trigger resequence, we need midpoint to conflict with an EXISTING ordinal
+ // AFTER the range deletion. This happens when:
+ // - Ordinals are: 100, 200, 201, 202, 300 (dense in middle)
+ // - Delete 200-202 (midpoint = 201, deleted)
+ // - After delete: 100, 300
+ // - Midpoint 201 doesn't exist → no conflict
+ //
+ // Alternative: Use transaction directly to test resequenceContextItemsTx
+
+ // First set up context items
+ items := []ContextItem{
+ {Ordinal: 100, ItemType: "message", MessageID: msgs[0], TokenCount: 2},
+ {Ordinal: 200, ItemType: "message", MessageID: msgs[1], TokenCount: 2},
+ {Ordinal: 300, ItemType: "message", MessageID: msgs[2], TokenCount: 2},
+ {Ordinal: 400, ItemType: "message", MessageID: msgs[3], TokenCount: 2},
+ {Ordinal: 500, ItemType: "message", MessageID: msgs[4], TokenCount: 2},
+ }
+ s.UpsertContextItems(ctx, conv.ConversationID, items)
+
+ // Create a summary
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "summary", TokenCount: 5,
+ })
+
+ // Call resequenceContextItemsTx directly via a transaction
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ t.Fatalf("BeginTx: %v", err)
+ }
+ defer tx.Rollback()
+
+ err = s.resequenceContextItemsTx(ctx, tx, conv.ConversationID, summary.SummaryID)
+ if err != nil {
+ t.Fatalf("resequenceContextItemsTx: %v", err)
+ }
+ tx.Commit()
+
+ // Verify ordinals are unique and properly spaced
+ result, _ := s.GetContextItems(ctx, conv.ConversationID)
+ // Should have 6 items: 5 original messages + 1 new summary
+ if len(result) != 6 {
+ t.Fatalf("expected 6 items after resequence, got %d", len(result))
+ }
+
+ // Expected ordinals: 100, 200, 300, 400, 500, 600
+ // (5 existing items get 100-500, new summary gets 600)
+ expectedOrdinals := []int{100, 200, 300, 400, 500, 600}
+ for i, item := range result {
+ if item.Ordinal != expectedOrdinals[i] {
+ t.Errorf("item[%d].Ordinal = %d, want %d", i, item.Ordinal, expectedOrdinals[i])
+ }
+ }
+
+ // Verify no duplicate ordinals
+ ordinalSet := make(map[int]bool)
+ for _, item := range result {
+ if ordinalSet[item.Ordinal] {
+ t.Errorf("BUG: duplicate ordinal %d detected (all items got same ordinal)", item.Ordinal)
+ }
+ ordinalSet[item.Ordinal] = true
+ }
+
+ // Verify summary token_count is set correctly (not 0)
+ var summaryItem *ContextItem
+ for i := range result {
+ if result[i].ItemType == "summary" {
+ summaryItem = &result[i]
+ break
+ }
+ }
+ if summaryItem == nil {
+ t.Fatal("no summary item found after resequence")
+ }
+ if summaryItem.TokenCount != 5 {
+ t.Errorf("summary item TokenCount = %d, want 5 (from summary.TokenCount)", summaryItem.TokenCount)
+ }
+}
+
+func TestStoreGetContextTokenCount(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+ msg, _ := s.AddMessage(ctx, conv.ConversationID, "user", "hello", 0)
+
+ s.UpsertContextItems(ctx, conv.ConversationID, []ContextItem{
+ {Ordinal: 100, ItemType: "message", MessageID: msg.ID, TokenCount: 42},
+ })
+
+ count, err := s.GetContextTokenCount(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("GetContextTokenCount: %v", err)
+ }
+ if count != 42 {
+ t.Errorf("token count = %d, want 42", count)
+ }
+}
+
+func TestStoreGetMaxOrdinal(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // No items yet
+ maxOrd, err := s.GetMaxOrdinal(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("GetMaxOrdinal (empty): %v", err)
+ }
+ if maxOrd != 0 {
+ t.Errorf("max ordinal (empty) = %d, want 0", maxOrd)
+ }
+
+ // Add items
+ msg1, _ := s.AddMessage(ctx, conv.ConversationID, "user", "a", 1)
+ msg2, _ := s.AddMessage(ctx, conv.ConversationID, "user", "b", 1)
+ s.UpsertContextItems(ctx, conv.ConversationID, []ContextItem{
+ {Ordinal: 100, ItemType: "message", MessageID: msg1.ID, TokenCount: 1},
+ {Ordinal: 250, ItemType: "message", MessageID: msg2.ID, TokenCount: 1},
+ })
+
+ maxOrd, _ = s.GetMaxOrdinal(ctx, conv.ConversationID)
+ if maxOrd != 250 {
+ t.Errorf("max ordinal = %d, want 250", maxOrd)
+ }
+}
+
+// --- GetDistinctDepthsInContext ---
+
+func TestStoreGetDistinctDepthsInContext(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // Empty context → no depths
+ depths, err := s.GetDistinctDepthsInContext(ctx, conv.ConversationID, 0)
+ if err != nil {
+ t.Fatalf("GetDistinctDepthsInContext (empty): %v", err)
+ }
+ if len(depths) != 0 {
+ t.Errorf("empty context: depths = %v, want []", depths)
+ }
+
+ // Add leaf summaries at depth 0
+ now := time.Now().UTC()
+ s1, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "leaf1", TokenCount: 10, EarliestAt: &now, LatestAt: &now,
+ })
+ s2, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "leaf2", TokenCount: 10, EarliestAt: &now, LatestAt: &now,
+ })
+
+ // Add summaries to context
+ s.UpsertContextItems(ctx, conv.ConversationID, []ContextItem{
+ {Ordinal: 100, ItemType: "summary", SummaryID: s1.SummaryID, TokenCount: 10},
+ {Ordinal: 200, ItemType: "summary", SummaryID: s2.SummaryID, TokenCount: 10},
+ })
+
+ // Should find depth 0
+ depths, err = s.GetDistinctDepthsInContext(ctx, conv.ConversationID, 0)
+ if err != nil {
+ t.Fatalf("GetDistinctDepthsInContext: %v", err)
+ }
+ if len(depths) != 1 || depths[0] != 0 {
+ t.Errorf("depths = %v, want [0]", depths)
+ }
+
+ // Add condensed at depth 1
+ c1, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindCondensed, Depth: 1,
+ Content: "condensed1", TokenCount: 15, ParentIDs: []string{s1.SummaryID, s2.SummaryID},
+ })
+ s.AppendContextSummary(ctx, conv.ConversationID, c1.SummaryID)
+
+ // Should find depths [0, 1] or [1, 0]
+ depths, _ = s.GetDistinctDepthsInContext(ctx, conv.ConversationID, 0)
+ if len(depths) != 2 {
+ t.Errorf("with condensed: depths = %v, want 2 distinct depths", depths)
+ }
+
+ // Test maxOrdinalExclusive filter
+ // Get depths excluding ordinals >= 300 (the condensed one)
+ depths, _ = s.GetDistinctDepthsInContext(ctx, conv.ConversationID, 300)
+ if len(depths) != 1 || depths[0] != 0 {
+ t.Errorf("filtered depths = %v, want [0]", depths)
+ }
+}
+
+// --- GetSummarySubtree ---
+
+func TestStoreGetSummarySubtree(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // Create leaf summaries
+ now := time.Now().UTC()
+ l1, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "leaf1", TokenCount: 10, EarliestAt: &now, LatestAt: &now,
+ })
+ l2, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "leaf2", TokenCount: 10, EarliestAt: &now, LatestAt: &now,
+ })
+ l3, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "leaf3", TokenCount: 10, EarliestAt: &now, LatestAt: &now,
+ })
+
+ // Condense l1+l2 → c1
+ c1, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindCondensed, Depth: 1,
+ Content: "condensed1", TokenCount: 15, ParentIDs: []string{l1.SummaryID, l2.SummaryID},
+ })
+
+ // Get subtree from c1
+ nodes, err := s.GetSummarySubtree(ctx, c1.SummaryID)
+ if err != nil {
+ t.Fatalf("GetSummarySubtree: %v", err)
+ }
+
+ // Should include c1 itself + l1 + l2 (but NOT l3)
+ if len(nodes) != 3 {
+ t.Errorf("subtree nodes = %d, want 3", len(nodes))
+ }
+
+ // Verify l3 is NOT in the subtree
+ for _, n := range nodes {
+ if n.SummaryID == l3.SummaryID {
+ t.Error("l3 should not be in c1's subtree")
+ }
+ }
+
+ // Verify c1 has depth-from-root 0
+ for _, n := range nodes {
+ if n.SummaryID == c1.SummaryID && n.DepthFromRoot != 0 {
+ t.Errorf("c1 depth-from-root = %d, want 0", n.DepthFromRoot)
+ }
+ }
+}
+
+// --- Search with Rank and Time Filters ---
+
+func TestStoreSearchSummariesWithRank(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // Create summaries with different content (for FTS matching)
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "machine learning neural network", TokenCount: 10,
+ })
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "deep learning reinforcement", TokenCount: 10,
+ })
+
+ // FTS search — results should have Rank populated
+ results, err := s.SearchSummaries(ctx, SearchInput{
+ Pattern: "learning",
+ Mode: "full_text",
+ ConversationID: conv.ConversationID,
+ })
+ if err != nil {
+ t.Fatalf("SearchSummaries: %v", err)
+ }
+ if len(results) < 1 {
+ t.Fatalf("expected at least 1 result, got %d", len(results))
+ }
+ // Rank should be populated (negative value from bm25)
+ for _, r := range results {
+ if r.Rank == 0 {
+ t.Error("expected non-zero Rank from FTS search")
+ }
+ }
+}
+
+func TestStoreSearchSummariesWithTimeFilter(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // Create a summary
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "important meeting notes", TokenCount: 10,
+ })
+
+ // Search with Since filter (now - 1 hour → should match)
+ since := time.Now().UTC().Add(-1 * time.Hour)
+ results, err := s.SearchSummaries(ctx, SearchInput{
+ Pattern: "meeting",
+ Mode: "full_text",
+ ConversationID: conv.ConversationID,
+ Since: &since,
+ })
+ if err != nil {
+ t.Fatalf("SearchSummaries with Since: %v", err)
+ }
+ if len(results) != 1 {
+ t.Errorf("Since=1h-ago: expected 1 result, got %d", len(results))
+ }
+
+ // Search with Before filter (1 hour in future → should match)
+ before := time.Now().UTC().Add(1 * time.Hour)
+ results, err = s.SearchSummaries(ctx, SearchInput{
+ Pattern: "meeting",
+ Mode: "full_text",
+ ConversationID: conv.ConversationID,
+ Before: &before,
+ })
+ if err != nil {
+ t.Fatalf("SearchSummaries with Before: %v", err)
+ }
+ if len(results) != 1 {
+ t.Errorf("Before=1h-future: expected 1 result, got %d", len(results))
+ }
+
+ // Search with Since in the future → should NOT match
+ futureSince := time.Now().UTC().Add(1 * time.Hour)
+ results, err = s.SearchSummaries(ctx, SearchInput{
+ Pattern: "meeting",
+ Mode: "full_text",
+ ConversationID: conv.ConversationID,
+ Since: &futureSince,
+ })
+ if err != nil {
+ t.Fatalf("SearchSummaries with future Since: %v", err)
+ }
+ if len(results) != 0 {
+ t.Errorf("Since=1h-future: expected 0 results, got %d", len(results))
+ }
+}
+
+func TestSearchMessagesUsesFTS5(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "test:fts5-messages")
+ convID := conv.ConversationID
+
+ // Add messages with searchable content
+ s.AddMessage(ctx, convID, "user", "The quick brown fox jumps over the lazy dog", 10)
+ s.AddMessage(ctx, convID, "assistant", "A response about something else entirely", 10)
+ s.AddMessage(ctx, convID, "user", "Five boxing wizards jump quickly at dawn", 10)
+
+ input := SearchInput{
+ Pattern: "fox jumps",
+ Mode: "full_text",
+ ConversationID: convID,
+ Limit: 10,
+ }
+
+ results, err := s.SearchMessages(ctx, input)
+ if err != nil {
+ t.Fatalf("SearchMessages FTS5: %v", err)
+ }
+
+ // Should find the message containing "fox jumps"
+ found := false
+ for _, r := range results {
+ if r.MessageID > 0 && contains(r.Snippet, "fox") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("FTS5 search should find message with 'fox jumps'")
+ }
+}
+
+func TestMessagesFTSTriggers(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "test:fts-triggers")
+ convID := conv.ConversationID
+
+ // Insert a message
+ _, err := s.AddMessage(ctx, convID, "user", "database migration completed successfully", 10)
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+
+ // Verify FTS table was populated by INSERT trigger
+ var count int
+ err = s.db.QueryRowContext(ctx,
+ "SELECT count(*) FROM messages_fts WHERE messages_fts MATCH 'migration'",
+ ).Scan(&count)
+ if err != nil {
+ t.Fatalf("query messages_fts: %v", err)
+ }
+ if count != 1 {
+ t.Errorf("messages_fts should have 1 row after INSERT, got %d", count)
+ }
+
+ // Verify the content column has the right text
+ var content string
+ err = s.db.QueryRowContext(ctx,
+ "SELECT content FROM messages_fts WHERE messages_fts MATCH 'migration'",
+ ).Scan(&content)
+ if err != nil {
+ t.Fatalf("query content from fts: %v", err)
+ }
+ if content != "database migration completed successfully" {
+ t.Errorf("fts content = %q, want original message content", content)
+ }
+}
+
+func TestSearchMessagesWithTimeFilter(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "test:msg-time")
+ convID := conv.ConversationID
+
+ // Add messages
+ s.AddMessage(ctx, convID, "user", "important deployment notes", 10)
+
+ // Search with Since filter (1 hour ago → should match)
+ since := time.Now().UTC().Add(-1 * time.Hour)
+ results, err := s.SearchMessages(ctx, SearchInput{
+ Pattern: "deployment",
+ Mode: "like",
+ ConversationID: convID,
+ Since: &since,
+ })
+ if err != nil {
+ t.Fatalf("SearchMessages with Since: %v", err)
+ }
+ if len(results) != 1 {
+ t.Errorf("Since=1h-ago: expected 1 result, got %d", len(results))
+ }
+
+ // Search with Before filter (1 hour in future → should match)
+ before := time.Now().UTC().Add(1 * time.Hour)
+ results, err = s.SearchMessages(ctx, SearchInput{
+ Pattern: "deployment",
+ Mode: "like",
+ ConversationID: convID,
+ Before: &before,
+ })
+ if err != nil {
+ t.Fatalf("SearchMessages with Before: %v", err)
+ }
+ if len(results) != 1 {
+ t.Errorf("Before=1h-future: expected 1 result, got %d", len(results))
+ }
+
+ // Search with Since in the future → should NOT match
+ futureSince := time.Now().UTC().Add(1 * time.Hour)
+ results, err = s.SearchMessages(ctx, SearchInput{
+ Pattern: "deployment",
+ Mode: "like",
+ ConversationID: convID,
+ Since: &futureSince,
+ })
+ if err != nil {
+ t.Fatalf("SearchMessages with future Since: %v", err)
+ }
+ if len(results) != 0 {
+ t.Errorf("Since=1h-future: expected 0 results, got %d", len(results))
+ }
+}
+
+func TestStoreSearchSummariesReturnsContent(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // Create a summary with known content
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "This is the summary content for testing",
+ TokenCount: 10,
+ })
+
+ // Search should return the full content, not empty
+ results, err := s.SearchSummaries(ctx, SearchInput{
+ Pattern: "summary content",
+ Mode: "like",
+ ConversationID: conv.ConversationID,
+ })
+ if err != nil {
+ t.Fatalf("SearchSummaries: %v", err)
+ }
+ if len(results) != 1 {
+ t.Fatalf("expected 1 result, got %d", len(results))
+ }
+ if results[0].Content == "" {
+ t.Error("SearchResult.Content is empty, want full summary content")
+ }
+ if results[0].Content != "This is the summary content for testing" {
+ t.Errorf("SearchResult.Content = %q, want %q", results[0].Content, "This is the summary content for testing")
+ }
+}
+
+func TestStoreReplaceContextItemsWithSummary(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test-replace-items")
+
+ // Create messages
+ msgs := make([]int64, 5)
+ for i := 0; i < 5; i++ {
+ m, _ := s.AddMessage(ctx, conv.ConversationID, "user", fmt.Sprintf("msg%d", i), 2)
+ msgs[i] = m.ID
+ }
+
+ // Create summaries
+ summaries := make([]string, 3)
+ for i := 0; i < 3; i++ {
+ sum, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: fmt.Sprintf("summary %d", i),
+ TokenCount: 10,
+ })
+ summaries[i] = sum.SummaryID
+ }
+
+ // Insert context items with a message in between summaries:
+ // Ordinals: 100 (summary0), 200 (message), 300 (summary1), 400 (summary2)
+ items := []ContextItem{
+ {Ordinal: 100, ItemType: "summary", SummaryID: summaries[0], TokenCount: 10},
+ {Ordinal: 200, ItemType: "message", MessageID: msgs[1], TokenCount: 2},
+ {Ordinal: 300, ItemType: "summary", SummaryID: summaries[1], TokenCount: 10},
+ {Ordinal: 400, ItemType: "summary", SummaryID: summaries[2], TokenCount: 10},
+ }
+ s.UpsertContextItems(ctx, conv.ConversationID, items)
+
+ // Create a new summary to replace with
+ newSummary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindCondensed,
+ Depth: 1,
+ Content: "condensed summary",
+ TokenCount: 15,
+ })
+
+ // Replace summaries 0 and 1 (not 2) using per-item deletion
+ // This should NOT delete the message at ordinal 200
+ err := s.ReplaceContextItemsWithSummary(
+ ctx, conv.ConversationID,
+ []string{summaries[0], summaries[1]},
+ newSummary.SummaryID)
+ if err != nil {
+ t.Fatalf("ReplaceContextItemsWithSummary: %v", err)
+ }
+
+ // Verify result: should have 3 items (message at 200, summary2 at 400, new summary)
+ result, _ := s.GetContextItems(ctx, conv.ConversationID)
+ if len(result) != 3 {
+ t.Fatalf("expected 3 items after replace, got %d", len(result))
+ }
+
+ // Verify message at ordinal 200 is preserved
+ messagePreserved := false
+ for _, item := range result {
+ if item.ItemType == "message" && item.MessageID == msgs[1] {
+ messagePreserved = true
+ break
+ }
+ }
+ if !messagePreserved {
+ t.Error("message at ordinal 200 should have been preserved")
+ }
+
+ // Verify summary2 at ordinal 400 is preserved
+ summary2Preserved := false
+ for _, item := range result {
+ if item.ItemType == "summary" && item.SummaryID == summaries[2] {
+ summary2Preserved = true
+ break
+ }
+ }
+ if !summary2Preserved {
+ t.Error("summary2 at ordinal 400 should have been preserved")
+ }
+
+ // Verify new summary exists
+ newSummaryFound := false
+ for _, item := range result {
+ if item.ItemType == "summary" && item.SummaryID == newSummary.SummaryID {
+ newSummaryFound = true
+ break
+ }
+ }
+ if !newSummaryFound {
+ t.Error("new summary should exist")
+ }
+
+ // Verify no duplicate ordinals
+ ordinalSet := make(map[int]bool)
+ for _, item := range result {
+ if ordinalSet[item.Ordinal] {
+ t.Errorf("duplicate ordinal %d detected", item.Ordinal)
+ }
+ ordinalSet[item.Ordinal] = true
+ }
+}
diff --git a/pkg/seahorse/tool_expand.go b/pkg/seahorse/tool_expand.go
new file mode 100644
index 000000000..749c9cd6c
--- /dev/null
+++ b/pkg/seahorse/tool_expand.go
@@ -0,0 +1,129 @@
+package seahorse
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/sipeed/picoclaw/pkg/tools"
+)
+
+// ExpandTool recovers full message content by ID.
+type ExpandTool struct {
+ engine *RetrievalEngine
+}
+
+func NewExpandTool(engine *RetrievalEngine) *ExpandTool {
+ return &ExpandTool{engine: engine}
+}
+
+func (t *ExpandTool) Name() string {
+ return "short_expand"
+}
+
+func (t *ExpandTool) Description() string {
+ return `Get full message content by ID.
+
+Use when short_grep returns messages and you need complete content (not just snippet).
+
+Parameters:
+- message_ids (required): Array of message ID strings (from short_grep results)
+
+Returns message with:
+- content: Full text content
+- parts: Structured content
+ - text: Full text
+ - tool_use: name, arguments, toolCallId
+ - tool_result: toolCallId only (content omitted - re-run tool if needed)
+ - media: mediaUri (file path), mimeType
+
+Notes:
+- tool_result content is not returned (can be large). Re-run the tool if you need the result.
+- Media files are stored on disk at mediaUri path, use bash to access.
+
+Example:
+ {"message_ids": ["10", "25"]}`
+}
+
+func (t *ExpandTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "message_ids": map[string]any{
+ "type": "array",
+ "items": map[string]any{"type": "string"},
+ "description": "Message IDs to expand (from short_grep results, e.g., [\"10\", \"25\"])",
+ },
+ },
+ "required": []string{"message_ids"},
+ }
+}
+
+func (t *ExpandTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
+ idsRaw, ok := args["message_ids"].([]any)
+ if !ok || len(idsRaw) == 0 {
+ return tools.ErrorResult(
+ "Missing required 'message_ids' argument. " +
+ "Example: {\"message_ids\": [\"10\", \"25\"]}")
+ }
+
+ // Parse message IDs
+ messageIDs := make([]int64, 0, len(idsRaw))
+ for _, id := range idsRaw {
+ switch v := id.(type) {
+ case string:
+ var n int64
+ if _, err := fmt.Sscanf(v, "%d", &n); err != nil {
+ return tools.ErrorResult(fmt.Sprintf("Invalid message_id %q: %v", v, err))
+ }
+ messageIDs = append(messageIDs, n)
+ case float64:
+ messageIDs = append(messageIDs, int64(v))
+ }
+ }
+
+ result, err := t.engine.ExpandMessages(ctx, messageIDs)
+ if err != nil {
+ return tools.ErrorResult("Expand failed: " + err.Error())
+ }
+
+ // Build response with filtered parts
+ messages := make([]map[string]any, 0, len(result.Messages))
+ for _, msg := range result.Messages {
+ parts := make([]map[string]any, 0, len(msg.Parts))
+ for _, p := range msg.Parts {
+ part := map[string]any{"type": p.Type}
+ switch p.Type {
+ case "text":
+ part["text"] = p.Text
+ case "tool_use":
+ part["name"] = p.Name
+ part["arguments"] = p.Arguments
+ part["toolCallId"] = p.ToolCallID
+ case "tool_result":
+ // Omit content - can be large, re-run tool if needed
+ part["toolCallId"] = p.ToolCallID
+ case "media":
+ part["mediaUri"] = p.MediaURI
+ part["mimeType"] = p.MimeType
+ }
+ parts = append(parts, part)
+ }
+
+ messages = append(messages, map[string]any{
+ "id": fmt.Sprintf("%d", msg.ID),
+ "role": msg.Role,
+ "content": msg.Content,
+ "parts": parts,
+ "conversationId": msg.ConversationID,
+ })
+ }
+
+ output := map[string]any{
+ "success": true,
+ "tokenCount": result.TokenCount,
+ "messages": messages,
+ }
+ data, _ := json.Marshal(output)
+ return tools.NewToolResult(string(data))
+}
diff --git a/pkg/seahorse/tool_expand_test.go b/pkg/seahorse/tool_expand_test.go
new file mode 100644
index 000000000..fc726a7a0
--- /dev/null
+++ b/pkg/seahorse/tool_expand_test.go
@@ -0,0 +1,136 @@
+package seahorse
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "testing"
+)
+
+func TestExpandToolByMessageIDs(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "test:expand-tool")
+
+ msg1, _ := s.AddMessage(ctx, conv.ConversationID, "user", "first message", 10)
+ msg2, _ := s.AddMessage(ctx, conv.ConversationID, "assistant", "second message", 10)
+
+ re := &RetrievalEngine{store: s}
+ tool := NewExpandTool(re)
+
+ result := tool.Execute(ctx, map[string]any{
+ "message_ids": []any{fmt.Sprintf("%d", msg1.ID), fmt.Sprintf("%d", msg2.ID)},
+ })
+
+ if result.IsError {
+ t.Fatalf("Expand failed: %s", result.ForLLM)
+ }
+
+ // Parse result
+ var output struct {
+ Success bool `json:"success"`
+ TokenCount int `json:"tokenCount"`
+ Messages []map[string]any `json:"messages"`
+ }
+ if err := json.Unmarshal([]byte(result.ForLLM), &output); err != nil {
+ t.Fatalf("Parse result: %v", err)
+ }
+
+ if !output.Success {
+ t.Error("expected success=true")
+ }
+ if len(output.Messages) != 2 {
+ t.Errorf("Messages = %d, want 2", len(output.Messages))
+ }
+ if output.TokenCount != 20 {
+ t.Errorf("TokenCount = %d, want 20", output.TokenCount)
+ }
+}
+
+func TestExpandToolMissingIDs(t *testing.T) {
+ s := openTestStore(t)
+ re := &RetrievalEngine{store: s}
+ tool := NewExpandTool(re)
+
+ result := tool.Execute(context.Background(), map[string]any{})
+
+ if !result.IsError {
+ t.Error("expected error for missing message_ids")
+ }
+}
+
+func TestExpandToolWithParts(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "test:expand-parts")
+
+ // Create message with parts
+ parts := []MessagePart{
+ {Type: "text", Text: "Hello"},
+ {Type: "tool_use", Name: "bash", Arguments: `{"command":"ls"}`, ToolCallID: "call_123"},
+ {Type: "tool_result", ToolCallID: "call_123", Text: "file1.txt\nfile2.txt"},
+ }
+ msg, _ := s.AddMessageWithParts(ctx, conv.ConversationID, "assistant", parts, 50)
+
+ re := &RetrievalEngine{store: s}
+ tool := NewExpandTool(re)
+
+ result := tool.Execute(ctx, map[string]any{
+ "message_ids": []any{fmt.Sprintf("%d", msg.ID)},
+ })
+
+ if result.IsError {
+ t.Fatalf("Expand failed: %s", result.ForLLM)
+ }
+
+ var output struct {
+ Messages []struct {
+ Parts []map[string]any `json:"parts"`
+ } `json:"messages"`
+ }
+ if err := json.Unmarshal([]byte(result.ForLLM), &output); err != nil {
+ t.Fatalf("Parse result: %v", err)
+ }
+
+ if len(output.Messages) != 1 {
+ t.Fatalf("Messages = %d, want 1", len(output.Messages))
+ }
+
+ // Verify parts are filtered correctly
+ foundText := false
+ foundToolUse := false
+ foundToolResult := false
+ for _, p := range output.Messages[0].Parts {
+ switch p["type"].(string) {
+ case "text":
+ foundText = true
+ if p["text"] != "Hello" {
+ t.Errorf("text = %v, want Hello", p["text"])
+ }
+ case "tool_use":
+ foundToolUse = true
+ if p["name"] != "bash" {
+ t.Errorf("name = %v, want bash", p["name"])
+ }
+ case "tool_result":
+ foundToolResult = true
+ // tool_result should NOT have content
+ if _, hasContent := p["content"]; hasContent {
+ t.Error("tool_result should not have content field")
+ }
+ if p["toolCallId"] != "call_123" {
+ t.Errorf("toolCallId = %v, want call_123", p["toolCallId"])
+ }
+ }
+ }
+
+ if !foundText {
+ t.Error("missing text part")
+ }
+ if !foundToolUse {
+ t.Error("missing tool_use part")
+ }
+ if !foundToolResult {
+ t.Error("missing tool_result part")
+ }
+}
diff --git a/pkg/seahorse/tool_grep.go b/pkg/seahorse/tool_grep.go
new file mode 100644
index 000000000..9671d2a7f
--- /dev/null
+++ b/pkg/seahorse/tool_grep.go
@@ -0,0 +1,172 @@
+package seahorse
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/tools"
+)
+
+// GrepTool searches summaries and messages for matching content.
+type GrepTool struct {
+ engine *RetrievalEngine
+}
+
+func NewGrepTool(engine *RetrievalEngine) *GrepTool {
+ return &GrepTool{engine: engine}
+}
+
+func (t *GrepTool) Name() string {
+ return "short_grep"
+}
+
+func (t *GrepTool) Description() string {
+ return `Search summaries and messages for matching content.
+
+Pattern syntax:
+- Words: "authentication" - matches content containing this word
+- AND: "auth AND login" - matches content with both words
+- OR: "auth OR signin" - matches content with either word
+- NOT: "bug NOT fixed" - matches "bug" but excludes "fixed"
+- Wildcard: "%auth%" - matches any text containing "auth" (e.g., "auth", "authentication")
+
+Each summary has a "depth" field:
+- depth 0: Created from messages, most detailed
+- depth 1+: Created from other summaries, more compressed but covers longer time
+
+Parameters:
+- pattern (required): Search pattern
+- scope: "both" (default), "summary", or "message" - what to search
+- role: "user", "assistant", or omit for all - filter by message role
+- last: Time shortcut like "6h", "7d", "2w", "1m" (hours/days/weeks/months)
+- all_conversations: Search all conversations (default: current only)
+- since: ISO8601 timestamp, content after this time
+- before: ISO8601 timestamp, content before this time
+- limit: Max results (default: 20)
+
+Returns:
+{
+ "success": true,
+ "summaries": [{"id": "sum_abc", "content": "...", "depth": 0, "kind": "leaf", "conversationId": 1, "rank": -0.5}],
+ "messages": [{"id": "10", "snippet": "...matched...", "role": "user", "conversationId": 1, "rank": -1.2}],
+ "totalSummaries": 5,
+ "totalMessages": 10,
+ "hint": "No matches. Try: %keyword% for fuzzy search"
+}
+
+Rank field (FTS5 mode only): bm25 relevance score, negative value where more negative = higher relevance.
+Examples: -5=excellent, -2=good, -0.5=partial. LIKE mode (%pattern%) has no rank.
+
+Examples:
+ {"pattern": "authentication"}
+ {"pattern": "bug AND login"}
+ {"pattern": "%snake%"}
+ {"pattern": "project", "scope": "summary"}
+ {"pattern": "error", "role": "assistant", "last": "7d"}
+ {"pattern": "error", "all_conversations": true}`
+}
+
+func (t *GrepTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "pattern": map[string]any{
+ "type": "string",
+ "description": "Search pattern. Supports: words, AND/OR/NOT operators, % wildcard",
+ },
+ "scope": map[string]any{
+ "type": "string",
+ "enum": []string{"both", "summary", "message"},
+ "description": "What to search: 'both' (default), 'summary', or 'message'",
+ },
+ "role": map[string]any{
+ "type": "string",
+ "enum": []string{"user", "assistant"},
+ "description": "Filter by message role (default: all roles)",
+ },
+ "last": map[string]any{
+ "type": "string",
+ "description": "Time shortcut: '6h' (6 hours), '7d' (7 days), '2w' (2 weeks), '1m' (1 month)",
+ },
+ "all_conversations": map[string]any{
+ "type": "boolean",
+ "description": "Search across all conversations (default: searches current conversation only)",
+ },
+ "since": map[string]any{
+ "type": "string",
+ "description": "ISO8601 timestamp, only return content after this time",
+ },
+ "before": map[string]any{
+ "type": "string",
+ "description": "ISO8601 timestamp, only return content before this time",
+ },
+ "limit": map[string]any{
+ "type": "integer",
+ "description": "Maximum number of results (default: 20)",
+ },
+ },
+ "required": []string{"pattern"},
+ }
+}
+
+func (t *GrepTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
+ pattern, ok := args["pattern"].(string)
+ if !ok || pattern == "" {
+ return tools.ErrorResult("Missing required 'pattern' argument. Example: {\"pattern\": \"authentication\"}")
+ }
+
+ input := GrepInput{Pattern: pattern}
+
+ if scope, ok := args["scope"].(string); ok && scope != "" {
+ input.Scope = scope
+ }
+ if role, ok := args["role"].(string); ok && role != "" {
+ input.Role = role
+ }
+ if last, ok := args["last"].(string); ok && last != "" {
+ input.Last = last
+ }
+ if allConv, ok := args["all_conversations"].(bool); ok {
+ input.AllConversations = allConv
+ }
+ if limit, ok := args["limit"].(float64); ok {
+ input.Limit = int(limit)
+ }
+ if sinceStr, ok := args["since"].(string); ok && sinceStr != "" {
+ parsed, err := time.Parse(time.RFC3339, sinceStr)
+ if err != nil {
+ return tools.ErrorResult(fmt.Sprintf(
+ "Invalid 'since' timestamp. Use RFC3339 format like '2024-01-15T10:00:00Z'. Error: %v", err))
+ }
+ input.Since = &parsed
+ }
+ if beforeStr, ok := args["before"].(string); ok && beforeStr != "" {
+ parsed, err := time.Parse(time.RFC3339, beforeStr)
+ if err != nil {
+ return tools.ErrorResult(fmt.Sprintf("Invalid 'before' timestamp format: %v", err))
+ }
+ input.Before = &parsed
+ }
+
+ result, err := t.engine.Grep(ctx, input)
+ if err != nil {
+ return tools.ErrorResult("Grep failed: " + err.Error())
+ }
+
+ // Build response
+ output := map[string]any{
+ "success": result.Success,
+ "summaries": result.Summaries,
+ "messages": result.Messages,
+ }
+
+ // Add hint if provided
+ if result.Hint != "" {
+ output["hint"] = result.Hint
+ }
+
+ data, _ := json.Marshal(output)
+ return tools.NewToolResult(string(data))
+}
diff --git a/pkg/seahorse/tool_grep_test.go b/pkg/seahorse/tool_grep_test.go
new file mode 100644
index 000000000..050d9deeb
--- /dev/null
+++ b/pkg/seahorse/tool_grep_test.go
@@ -0,0 +1,72 @@
+package seahorse
+
+import (
+ "context"
+ "testing"
+)
+
+func TestGrepSearchSummaries(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "test:grep-tool")
+
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "database connection pool configuration",
+ TokenCount: 50,
+ })
+
+ re := &RetrievalEngine{store: s}
+ results, err := re.Grep(ctx, GrepInput{
+ Pattern: "database",
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ if len(results.Summaries) == 0 {
+ t.Error("expected at least 1 summary result")
+ }
+}
+
+func TestGrepSearchMessages(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "test:grep-msg")
+
+ s.AddMessage(ctx, conv.ConversationID, "user", "find this message about testing", 5)
+ s.AddMessage(ctx, conv.ConversationID, "user", "unrelated content", 3)
+
+ re := &RetrievalEngine{store: s}
+ results, err := re.Grep(ctx, GrepInput{
+ Pattern: "testing",
+ })
+ if err != nil {
+ t.Fatalf("Grep messages: %v", err)
+ }
+ if len(results.Messages) == 0 {
+ t.Error("expected at least 1 message result")
+ }
+}
+
+func TestGrepMissingPattern(t *testing.T) {
+ s := openTestStore(t)
+ re := &RetrievalEngine{store: s}
+ _, err := re.Grep(context.Background(), GrepInput{})
+ if err == nil {
+ t.Error("expected error for missing pattern")
+ }
+}
+
+func TestGrepToolSupportsAllConversations(t *testing.T) {
+ s := openTestStore(t)
+ tool := NewGrepTool(&RetrievalEngine{store: s})
+ params := tool.Parameters()
+ props := params["properties"].(map[string]any)
+
+ // GrepTool should accept all_conversations parameter
+ if _, ok := props["all_conversations"]; !ok {
+ t.Error("Parameters missing 'all_conversations' field")
+ }
+}
diff --git a/pkg/seahorse/types.go b/pkg/seahorse/types.go
new file mode 100644
index 000000000..2bc7f931f
--- /dev/null
+++ b/pkg/seahorse/types.go
@@ -0,0 +1,161 @@
+package seahorse
+
+import (
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/tokenizer"
+)
+
+// SummaryKind distinguishes leaf summaries (from raw messages) vs condensed
+// summaries (from other summaries).
+type SummaryKind string
+
+const (
+ SummaryKindLeaf SummaryKind = "leaf"
+ SummaryKindCondensed SummaryKind = "condensed"
+)
+
+// Message represents a single chat message with role and content.
+type Message struct {
+ ID int64 `json:"id"`
+ ConversationID int64 `json:"conversationId"`
+ Role string `json:"role"`
+ Content string `json:"content"`
+ ReasoningContent string `json:"reasoningContent,omitempty"`
+ TokenCount int `json:"tokenCount"`
+ CreatedAt time.Time `json:"createdAt"`
+ Parts []MessagePart `json:"parts,omitempty"`
+}
+
+// MessagePart holds structured content (tool calls, media, etc.)
+type MessagePart struct {
+ ID int64 `json:"id"`
+ MessageID int64 `json:"messageId"`
+ Type string `json:"type"` // "text", "tool_use", "tool_result", "media"
+ Text string `json:"text"`
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+ ToolCallID string `json:"toolCallId"`
+ MediaURI string `json:"mediaUri"`
+ MimeType string `json:"mimeType"`
+}
+
+// Summary represents a compressed representation of messages or other summaries.
+type Summary struct {
+ SummaryID string `json:"summaryId"`
+ ConversationID int64 `json:"conversationId"`
+ Kind SummaryKind `json:"kind"`
+ Depth int `json:"depth"`
+ Content string `json:"content"`
+ TokenCount int `json:"tokenCount"`
+ EarliestAt *time.Time `json:"earliestAt,omitempty"`
+ LatestAt *time.Time `json:"latestAt,omitempty"`
+ DescendantCount int `json:"descendantCount"`
+ DescendantTokenCount int `json:"descendantTokenCount"`
+ SourceMessageTokenCount int `json:"sourceMessageTokenCount"`
+ Model string `json:"model"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+// SummaryNode is a Summary with graph relationships for tree traversal.
+type SummaryNode struct {
+ Summary
+ Children []string `json:"children"` // Child summary IDs
+ Expanded bool `json:"expanded"` // UI state for expansion
+}
+
+// Conversation represents a session's conversation with metadata.
+type Conversation struct {
+ ConversationID int64 `json:"conversationId"`
+ SessionKey string `json:"sessionKey"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+// SessionStatus contains status information for a session.
+type SessionStatus struct {
+ SessionKey string `json:"sessionKey"`
+ ConversationID int64 `json:"conversationId"`
+ Messages int `json:"messages"`
+ TotalTokens int `json:"totalTokens"`
+ Summaries int `json:"summaries"`
+ OldestAt time.Time `json:"oldestAt"`
+ NewestAt time.Time `json:"newestAt"`
+}
+
+// ContextItem represents one item in the assembled context window.
+type ContextItem struct {
+ ConversationID int64 `json:"conversationId"`
+ Ordinal int `json:"ordinal"`
+ ItemType string `json:"itemType"` // "summary" or "message"
+ SummaryID string `json:"summaryId,omitempty"`
+ MessageID int64 `json:"messageId,omitempty"`
+ TokenCount int `json:"tokenCount"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+// SummarySubtreeNode is a node in a summary DAG subtree.
+type SummarySubtreeNode struct {
+ SummaryID string `json:"summaryId"`
+ DepthFromRoot int `json:"depthFromRoot"`
+}
+
+// SearchInput controls summary search.
+type SearchInput struct {
+ Pattern string `json:"pattern"`
+ Mode string `json:"mode"` // "like" (LIKE search) or "full_text" (FTS5, default)
+ Scope string `json:"scope,omitempty"` // "messages", "summaries", "both"
+ Role string `json:"role,omitempty"` // "user", "assistant", or "" (all)
+ Since *time.Time `json:"since,omitempty"`
+ Before *time.Time `json:"before,omitempty"`
+ Limit int `json:"limit,omitempty"`
+ ConversationID int64 `json:"conversationId,omitempty"`
+ AllConversations bool `json:"allConversations,omitempty"`
+}
+
+// SearchResult is a search match.
+type SearchResult struct {
+ SummaryID string `json:"summaryId,omitempty"`
+ MessageID int64 `json:"messageId,omitempty"`
+ ConversationID int64 `json:"conversationId"`
+ Kind SummaryKind `json:"kind,omitempty"`
+ Depth int `json:"depth,omitempty"`
+ Role string `json:"role,omitempty"`
+ Content string `json:"content,omitempty"` // Full content for summaries
+ Snippet string `json:"snippet"`
+ CreatedAt time.Time `json:"createdAt"`
+ Rank float64 `json:"rank,omitempty"`
+ TotalCount int `json:"totalCount,omitempty"` // Total matching rows (from window function)
+}
+
+// EstimateMessageTokens estimates token count for a full message using the
+// shared tokenizer package for consistency with agent.context_budget.
+func EstimateMessageTokens(msg Message) int {
+ pm := providers.Message{
+ Role: msg.Role,
+ Content: msg.Content,
+ ReasoningContent: msg.ReasoningContent,
+ }
+
+ // Convert MessageParts to ToolCalls / ToolCallID / Media
+ for _, part := range msg.Parts {
+ switch part.Type {
+ case "tool_use":
+ pm.ToolCalls = append(pm.ToolCalls, providers.ToolCall{
+ ID: part.ToolCallID,
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: part.Name,
+ Arguments: part.Arguments,
+ },
+ })
+ case "tool_result":
+ pm.ToolCallID = part.ToolCallID
+ case "media":
+ pm.Media = append(pm.Media, part.MediaURI)
+ }
+ }
+
+ return tokenizer.EstimateMessageTokens(pm)
+}
diff --git a/pkg/seahorse/types_test.go b/pkg/seahorse/types_test.go
new file mode 100644
index 000000000..b7467005f
--- /dev/null
+++ b/pkg/seahorse/types_test.go
@@ -0,0 +1,54 @@
+package seahorse
+
+import (
+ "testing"
+)
+
+func TestSummaryKindValues(t *testing.T) {
+ if SummaryKindLeaf != "leaf" {
+ t.Errorf("expected SummaryKindLeaf = 'leaf', got %q", SummaryKindLeaf)
+ }
+ if SummaryKindCondensed != "condensed" {
+ t.Errorf("expected SummaryKindCondensed = 'condensed', got %q", SummaryKindCondensed)
+ }
+}
+
+func TestConstants(t *testing.T) {
+ // Ordinal gap step
+ if OrdinalStep != 100 {
+ t.Errorf("expected OrdinalStep = 100, got %d", OrdinalStep)
+ }
+
+ // Compaction triggers
+ if ContextThreshold != 0.75 {
+ t.Errorf("expected ContextThreshold = 0.75, got %f", ContextThreshold)
+ }
+ if FreshTailCount != 32 {
+ t.Errorf("expected FreshTailCount = 32, got %d", FreshTailCount)
+ }
+
+ // Fanout
+ if LeafMinFanout != 8 {
+ t.Errorf("expected LeafMinFanout = 8, got %d", LeafMinFanout)
+ }
+ if CondensedMinFanout != 4 {
+ t.Errorf("expected CondensedMinFanout = 4, got %d", CondensedMinFanout)
+ }
+ if CondensedMinFanoutHard != 2 {
+ t.Errorf("expected CondensedMinFanoutHard = 2, got %d", CondensedMinFanoutHard)
+ }
+
+ // Token targets
+ if LeafChunkTokens != 20000 {
+ t.Errorf("expected LeafChunkTokens = 20000, got %d", LeafChunkTokens)
+ }
+ if LeafTargetTokens != 1200 {
+ t.Errorf("expected LeafTargetTokens = 1200, got %d", LeafTargetTokens)
+ }
+ if CondensedTargetTokens != 2000 {
+ t.Errorf("expected CondensedTargetTokens = 2000, got %d", CondensedTargetTokens)
+ }
+ if MaxExpandTokens != 4000 {
+ t.Errorf("expected MaxExpandTokens = 4000, got %d", MaxExpandTokens)
+ }
+}
diff --git a/pkg/session/allocator.go b/pkg/session/allocator.go
new file mode 100644
index 000000000..509550cb2
--- /dev/null
+++ b/pkg/session/allocator.go
@@ -0,0 +1,213 @@
+package session
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/routing"
+)
+
+// Allocation contains the concrete session keys selected for a routed turn.
+// The current implementation intentionally preserves the legacy session-key
+// layout while moving key construction out of the router.
+type Allocation struct {
+ Scope SessionScope
+ SessionKey string
+ SessionAliases []string
+ MainSessionKey string
+ MainAliases []string
+}
+
+// AllocationInput contains the routing result and peer context needed to
+// derive the session keys for a turn.
+type AllocationInput struct {
+ AgentID string
+ Context bus.InboundContext
+ SessionPolicy routing.SessionPolicy
+}
+
+// AllocateRouteSession maps a route decision onto a structured scope and the
+// current opaque session-key format.
+func AllocateRouteSession(input AllocationInput) Allocation {
+ scope := buildSessionScope(input)
+ legacySessionAliases := buildLegacySessionAliases(input)
+ legacyMainSessionKey := strings.ToLower(BuildLegacyMainAlias(input.AgentID))
+ return Allocation{
+ Scope: scope,
+ SessionKey: BuildSessionKey(scope),
+ SessionAliases: legacySessionAliases,
+ MainSessionKey: BuildOpaqueSessionKey(legacyMainSessionKey),
+ MainAliases: []string{legacyMainSessionKey},
+ }
+}
+
+func buildSessionScope(input AllocationInput) SessionScope {
+ inbound := input.Context
+ includeTopicInChatDimension := shouldPreserveTelegramForumIsolation(input)
+ scope := SessionScope{
+ Version: ScopeVersionV1,
+ AgentID: routing.NormalizeAgentID(input.AgentID),
+ Channel: strings.ToLower(strings.TrimSpace(inbound.Channel)),
+ Account: routing.NormalizeAccountID(inbound.Account),
+ }
+ if scope.Channel == "" {
+ scope.Channel = "unknown"
+ }
+
+ dimensions := make([]string, 0, len(input.SessionPolicy.Dimensions))
+ values := make(map[string]string, len(input.SessionPolicy.Dimensions))
+
+ for _, dimension := range input.SessionPolicy.Dimensions {
+ switch dimension {
+ case "space":
+ if spaceID := strings.TrimSpace(inbound.SpaceID); spaceID != "" {
+ spaceType := strings.ToLower(strings.TrimSpace(inbound.SpaceType))
+ if spaceType == "" {
+ spaceType = "space"
+ }
+ dimensions = append(dimensions, "space")
+ values["space"] = fmt.Sprintf("%s:%s", spaceType, strings.ToLower(spaceID))
+ }
+ case "chat":
+ chatID := strings.TrimSpace(inbound.ChatID)
+ if chatID == "" {
+ continue
+ }
+ if includeTopicInChatDimension {
+ if topicID := strings.TrimSpace(inbound.TopicID); topicID != "" {
+ chatID = chatID + "/" + topicID
+ }
+ }
+ chatType := strings.ToLower(strings.TrimSpace(inbound.ChatType))
+ if chatType == "" {
+ chatType = "direct"
+ }
+ dimensions = append(dimensions, "chat")
+ values["chat"] = fmt.Sprintf("%s:%s", chatType, strings.ToLower(chatID))
+ case "topic":
+ if topicID := strings.TrimSpace(inbound.TopicID); topicID != "" {
+ dimensions = append(dimensions, "topic")
+ values["topic"] = "topic:" + strings.ToLower(topicID)
+ }
+ case "sender":
+ senderID := CanonicalSessionIdentityID(
+ inbound.Channel,
+ inbound.SenderID,
+ input.SessionPolicy.IdentityLinks,
+ )
+ if senderID == "" {
+ continue
+ }
+ dimensions = append(dimensions, "sender")
+ values["sender"] = senderID
+ }
+ }
+
+ if len(dimensions) > 0 {
+ scope.Dimensions = dimensions
+ scope.Values = values
+ }
+
+ return scope
+}
+
+func buildLegacySessionAliases(input AllocationInput) []string {
+ aliases := []string{strings.ToLower(BuildLegacyMainAlias(input.AgentID))}
+ inbound := input.Context
+
+ if strings.EqualFold(strings.TrimSpace(inbound.ChatType), "direct") {
+ peerIDs := buildLegacyDirectPeerIDs(input)
+ if len(peerIDs) == 0 {
+ return uniqueAliases(aliases)
+ }
+ for _, peerID := range peerIDs {
+ aliases = append(
+ aliases,
+ BuildLegacyDirectAliases(input.AgentID, inbound.Channel, inbound.Account, peerID)...,
+ )
+ }
+ return uniqueAliases(aliases)
+ }
+
+ peerID := strings.TrimSpace(inbound.ChatID)
+ if peerID == "" {
+ return uniqueAliases(aliases)
+ }
+ if topicID := strings.TrimSpace(inbound.TopicID); topicID != "" {
+ peerID = peerID + "/" + topicID
+ }
+ aliases = append(aliases, BuildLegacyPeerAlias(
+ input.AgentID,
+ inbound.Channel,
+ strings.ToLower(strings.TrimSpace(inbound.ChatType)),
+ peerID,
+ ))
+
+ return uniqueAliases(aliases)
+}
+
+func shouldPreserveTelegramForumIsolation(input AllocationInput) bool {
+ inbound := input.Context
+ if !strings.EqualFold(strings.TrimSpace(inbound.Channel), "telegram") {
+ return false
+ }
+ if strings.TrimSpace(inbound.TopicID) == "" {
+ return false
+ }
+ for _, dimension := range input.SessionPolicy.Dimensions {
+ if strings.EqualFold(strings.TrimSpace(dimension), "topic") {
+ return false
+ }
+ }
+ return true
+}
+
+func buildLegacyDirectPeerIDs(input AllocationInput) []string {
+ inbound := input.Context
+ peerIDs := make([]string, 0, 3)
+
+ rawSenderID := strings.TrimSpace(inbound.SenderID)
+ if rawSenderID != "" {
+ peerIDs = append(peerIDs, strings.ToLower(rawSenderID))
+ }
+
+ canonicalSenderID := CanonicalSessionIdentityID(
+ inbound.Channel,
+ inbound.SenderID,
+ input.SessionPolicy.IdentityLinks,
+ )
+ if canonicalSenderID != "" {
+ peerIDs = append(peerIDs, canonicalSenderID)
+ }
+
+ chatID := strings.TrimSpace(inbound.ChatID)
+ if chatID != "" {
+ peerIDs = append(peerIDs, strings.ToLower(chatID))
+ }
+
+ return uniqueAliases(peerIDs)
+}
+
+func uniqueAliases(aliases []string) []string {
+ if len(aliases) == 0 {
+ return nil
+ }
+ normalized := make([]string, 0, len(aliases))
+ seen := make(map[string]struct{}, len(aliases))
+ for _, alias := range aliases {
+ alias = strings.TrimSpace(strings.ToLower(alias))
+ if alias == "" {
+ continue
+ }
+ if _, ok := seen[alias]; ok {
+ continue
+ }
+ seen[alias] = struct{}{}
+ normalized = append(normalized, alias)
+ }
+ if len(normalized) == 0 {
+ return nil
+ }
+ return normalized
+}
diff --git a/pkg/session/allocator_test.go b/pkg/session/allocator_test.go
new file mode 100644
index 000000000..9750ffc39
--- /dev/null
+++ b/pkg/session/allocator_test.go
@@ -0,0 +1,160 @@
+package session
+
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/routing"
+)
+
+func TestAllocateRouteSession_PerPeerDM(t *testing.T) {
+ allocation := AllocateRouteSession(AllocationInput{
+ AgentID: "main",
+ Context: bus.InboundContext{
+ Channel: "telegram",
+ Account: "default",
+ ChatID: "dm-123",
+ ChatType: "direct",
+ SenderID: "User123",
+ },
+ SessionPolicy: routing.SessionPolicy{
+ Dimensions: []string{"sender"},
+ },
+ })
+
+ if allocation.SessionKey == "" || !IsOpaqueSessionKey(allocation.SessionKey) {
+ t.Fatalf("SessionKey = %q, want opaque session key", allocation.SessionKey)
+ }
+ if !containsAlias(allocation.SessionAliases, "agent:main:direct:user123") {
+ t.Fatalf("SessionAliases = %v, want to contain agent:main:direct:user123", allocation.SessionAliases)
+ }
+ if allocation.MainSessionKey == "" || !IsOpaqueSessionKey(allocation.MainSessionKey) {
+ t.Fatalf("MainSessionKey = %q, want opaque session key", allocation.MainSessionKey)
+ }
+ if len(allocation.MainAliases) != 1 || allocation.MainAliases[0] != "agent:main:main" {
+ t.Fatalf("MainAliases = %v, want [agent:main:main]", allocation.MainAliases)
+ }
+ if allocation.Scope.Version != ScopeVersionV1 {
+ t.Fatalf("Scope.Version = %d, want %d", allocation.Scope.Version, ScopeVersionV1)
+ }
+ if len(allocation.Scope.Dimensions) != 1 || allocation.Scope.Dimensions[0] != "sender" {
+ t.Fatalf("Scope.Dimensions = %v, want [sender]", allocation.Scope.Dimensions)
+ }
+ if allocation.Scope.Values["sender"] != "user123" {
+ t.Fatalf("Scope.Values[sender] = %q, want user123", allocation.Scope.Values["sender"])
+ }
+}
+
+func TestAllocateRouteSession_GroupPeer(t *testing.T) {
+ allocation := AllocateRouteSession(AllocationInput{
+ AgentID: "main",
+ Context: bus.InboundContext{
+ Channel: "slack",
+ Account: "workspace-a",
+ ChatID: "C001",
+ ChatType: "channel",
+ SenderID: "U001",
+ },
+ SessionPolicy: routing.SessionPolicy{
+ Dimensions: []string{"chat"},
+ },
+ })
+
+ if allocation.SessionKey == "" || !IsOpaqueSessionKey(allocation.SessionKey) {
+ t.Fatalf("SessionKey = %q, want opaque session key", allocation.SessionKey)
+ }
+ if !containsAlias(allocation.SessionAliases, "agent:main:slack:channel:c001") {
+ t.Fatalf("SessionAliases = %v, want to contain agent:main:slack:channel:c001", allocation.SessionAliases)
+ }
+ if allocation.MainSessionKey == "" || !IsOpaqueSessionKey(allocation.MainSessionKey) {
+ t.Fatalf("MainSessionKey = %q, want opaque session key", allocation.MainSessionKey)
+ }
+ if len(allocation.MainAliases) != 1 || allocation.MainAliases[0] != "agent:main:main" {
+ t.Fatalf("MainAliases = %v, want [agent:main:main]", allocation.MainAliases)
+ }
+ if len(allocation.Scope.Dimensions) != 1 || allocation.Scope.Dimensions[0] != "chat" {
+ t.Fatalf("Scope.Dimensions = %v, want [chat]", allocation.Scope.Dimensions)
+ }
+ if allocation.Scope.Values["chat"] != "channel:c001" {
+ t.Fatalf("Scope.Values[chat] = %q, want channel:c001", allocation.Scope.Values["chat"])
+ }
+}
+
+func TestAllocateRouteSession_TelegramForumTopicsRemainIsolatedByDefault(t *testing.T) {
+ first := AllocateRouteSession(AllocationInput{
+ AgentID: "main",
+ Context: bus.InboundContext{
+ Channel: "telegram",
+ ChatID: "-1001234567890",
+ ChatType: "group",
+ TopicID: "42",
+ SenderID: "7",
+ },
+ SessionPolicy: routing.SessionPolicy{
+ Dimensions: []string{"chat"},
+ },
+ })
+ second := AllocateRouteSession(AllocationInput{
+ AgentID: "main",
+ Context: bus.InboundContext{
+ Channel: "telegram",
+ ChatID: "-1001234567890",
+ ChatType: "group",
+ TopicID: "99",
+ SenderID: "7",
+ },
+ SessionPolicy: routing.SessionPolicy{
+ Dimensions: []string{"chat"},
+ },
+ })
+
+ if first.SessionKey == second.SessionKey {
+ t.Fatalf("forum topics should not share default session key: %q", first.SessionKey)
+ }
+ if got := first.Scope.Values["chat"]; got != "group:-1001234567890/42" {
+ t.Fatalf("first.Scope.Values[chat] = %q, want %q", got, "group:-1001234567890/42")
+ }
+ if got := second.Scope.Values["chat"]; got != "group:-1001234567890/99" {
+ t.Fatalf("second.Scope.Values[chat] = %q, want %q", got, "group:-1001234567890/99")
+ }
+}
+
+func TestAllocateRouteSession_PicoDirectAliasesIncludeLegacyChatKey(t *testing.T) {
+ allocation := AllocateRouteSession(AllocationInput{
+ AgentID: "main",
+ Context: bus.InboundContext{
+ Channel: "pico",
+ Account: "default",
+ ChatID: "pico:session-123",
+ ChatType: "direct",
+ SenderID: "pico-user",
+ },
+ SessionPolicy: routing.SessionPolicy{
+ Dimensions: []string{"sender"},
+ },
+ })
+
+ if !containsAlias(allocation.SessionAliases, "agent:main:pico:direct:pico:session-123") {
+ t.Fatalf("SessionAliases = %v, want pico legacy alias", allocation.SessionAliases)
+ }
+}
+
+func TestBuildOpaqueSessionKey_IsStable(t *testing.T) {
+ first := BuildOpaqueSessionKey("agent:main:direct:user123")
+ second := BuildOpaqueSessionKey("agent:main:direct:user123")
+ if first != second {
+ t.Fatalf("BuildOpaqueSessionKey() mismatch: %q != %q", first, second)
+ }
+ if !IsOpaqueSessionKey(first) {
+ t.Fatalf("expected opaque session key, got %q", first)
+ }
+}
+
+func containsAlias(aliases []string, want string) bool {
+ for _, alias := range aliases {
+ if alias == want {
+ return true
+ }
+ }
+ return false
+}
diff --git a/pkg/session/jsonl_backend.go b/pkg/session/jsonl_backend.go
index 7f470de15..68ef2d753 100644
--- a/pkg/session/jsonl_backend.go
+++ b/pkg/session/jsonl_backend.go
@@ -2,7 +2,9 @@ package session
import (
"context"
+ "encoding/json"
"log"
+ "strings"
"github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/providers"
@@ -15,24 +17,123 @@ type JSONLBackend struct {
store memory.Store
}
+type metaAwareStore interface {
+ GetSessionMeta(ctx context.Context, sessionKey string) (memory.SessionMeta, error)
+ UpsertSessionMeta(ctx context.Context, sessionKey string, scope json.RawMessage, aliases []string) error
+ ResolveSessionKey(ctx context.Context, sessionKey string) (string, bool, error)
+}
+
+type aliasPromotingStore interface {
+ PromoteAliasHistory(ctx context.Context, sessionKey string, scope json.RawMessage, aliases []string) (bool, error)
+}
+
+// MetadataAwareSessionStore exposes structured session metadata operations.
+type MetadataAwareSessionStore interface {
+ EnsureSessionMetadata(sessionKey string, scope *SessionScope, aliases []string)
+ ResolveSessionKey(sessionKey string) string
+ GetSessionScope(sessionKey string) *SessionScope
+}
+
// NewJSONLBackend wraps a memory.Store for use as a SessionStore.
func NewJSONLBackend(store memory.Store) *JSONLBackend {
return &JSONLBackend{store: store}
}
+func (b *JSONLBackend) resolveSessionKey(sessionKey string) string {
+ metaStore, ok := b.store.(metaAwareStore)
+ if !ok {
+ return sessionKey
+ }
+ resolved, found, err := metaStore.ResolveSessionKey(context.Background(), sessionKey)
+ if err != nil {
+ log.Printf("session: resolve session key: %v", err)
+ return sessionKey
+ }
+ if found && resolved != "" {
+ return resolved
+ }
+ return sessionKey
+}
+
+// ResolveSessionKey maps aliases onto their canonical session key when the
+// underlying store supports structured metadata. Unknown aliases fall back to
+// the original input so existing callers remain compatible.
+func (b *JSONLBackend) ResolveSessionKey(sessionKey string) string {
+ return b.resolveSessionKey(sessionKey)
+}
+
+// EnsureSessionMetadata persists scope and alias metadata for a session.
+func (b *JSONLBackend) EnsureSessionMetadata(sessionKey string, scope *SessionScope, aliases []string) {
+ metaStore, ok := b.store.(metaAwareStore)
+ if !ok {
+ return
+ }
+ sessionKey = strings.TrimSpace(sessionKey)
+ if sessionKey == "" {
+ return
+ }
+
+ var rawScope json.RawMessage
+ if scope != nil {
+ data, err := json.Marshal(scope)
+ if err != nil {
+ log.Printf("session: encode session scope: %v", err)
+ return
+ }
+ rawScope = data
+ }
+ ctx := context.Background()
+ if err := metaStore.UpsertSessionMeta(ctx, sessionKey, rawScope, aliases); err != nil {
+ log.Printf("session: upsert session metadata: %v", err)
+ return
+ }
+
+ if promotingStore, ok := b.store.(aliasPromotingStore); ok {
+ if _, err := promotingStore.PromoteAliasHistory(ctx, sessionKey, rawScope, aliases); err != nil {
+ log.Printf("session: promote alias history: %v", err)
+ }
+ }
+}
+
+// GetSessionScope reads structured scope metadata for a session key or alias.
+func (b *JSONLBackend) GetSessionScope(sessionKey string) *SessionScope {
+ metaStore, ok := b.store.(metaAwareStore)
+ if !ok {
+ return nil
+ }
+ sessionKey = b.resolveSessionKey(sessionKey)
+ meta, err := metaStore.GetSessionMeta(context.Background(), sessionKey)
+ if err != nil {
+ log.Printf("session: get session metadata: %v", err)
+ return nil
+ }
+ if len(meta.Scope) == 0 {
+ return nil
+ }
+ var scope SessionScope
+ if err := json.Unmarshal(meta.Scope, &scope); err != nil {
+ log.Printf("session: decode session scope: %v", err)
+ return nil
+ }
+ return CloneScope(&scope)
+}
+
func (b *JSONLBackend) AddMessage(sessionKey, role, content string) {
+ sessionKey = b.resolveSessionKey(sessionKey)
if err := b.store.AddMessage(context.Background(), sessionKey, role, content); err != nil {
log.Printf("session: add message: %v", err)
}
}
func (b *JSONLBackend) AddFullMessage(sessionKey string, msg providers.Message) {
+ sessionKey = b.resolveSessionKey(sessionKey)
if err := b.store.AddFullMessage(context.Background(), sessionKey, msg); err != nil {
log.Printf("session: add full message: %v", err)
}
}
func (b *JSONLBackend) GetHistory(key string) []providers.Message {
+ key = b.resolveSessionKey(key)
msgs, err := b.store.GetHistory(context.Background(), key)
if err != nil {
log.Printf("session: get history: %v", err)
@@ -42,6 +143,7 @@ func (b *JSONLBackend) GetHistory(key string) []providers.Message {
}
func (b *JSONLBackend) GetSummary(key string) string {
+ key = b.resolveSessionKey(key)
summary, err := b.store.GetSummary(context.Background(), key)
if err != nil {
log.Printf("session: get summary: %v", err)
@@ -51,18 +153,21 @@ func (b *JSONLBackend) GetSummary(key string) string {
}
func (b *JSONLBackend) SetSummary(key, summary string) {
+ key = b.resolveSessionKey(key)
if err := b.store.SetSummary(context.Background(), key, summary); err != nil {
log.Printf("session: set summary: %v", err)
}
}
func (b *JSONLBackend) SetHistory(key string, history []providers.Message) {
+ key = b.resolveSessionKey(key)
if err := b.store.SetHistory(context.Background(), key, history); err != nil {
log.Printf("session: set history: %v", err)
}
}
func (b *JSONLBackend) TruncateHistory(key string, keepLast int) {
+ key = b.resolveSessionKey(key)
if err := b.store.TruncateHistory(context.Background(), key, keepLast); err != nil {
log.Printf("session: truncate history: %v", err)
}
@@ -72,6 +177,7 @@ func (b *JSONLBackend) TruncateHistory(key string, keepLast int) {
// immediately, the data is already durable. Save runs compaction to reclaim
// space from logically truncated messages (no-op when there are none).
func (b *JSONLBackend) Save(key string) error {
+ key = b.resolveSessionKey(key)
return b.store.Compact(context.Background(), key)
}
@@ -79,3 +185,8 @@ func (b *JSONLBackend) Save(key string) error {
func (b *JSONLBackend) Close() error {
return b.store.Close()
}
+
+// ListSessions returns all known session keys.
+func (b *JSONLBackend) ListSessions() []string {
+ return b.store.ListSessions()
+}
diff --git a/pkg/session/jsonl_backend_test.go b/pkg/session/jsonl_backend_test.go
index 40fa019cb..0b79ad84d 100644
--- a/pkg/session/jsonl_backend_test.go
+++ b/pkg/session/jsonl_backend_test.go
@@ -4,8 +4,10 @@ import (
"fmt"
"testing"
+ "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/session"
)
@@ -177,3 +179,126 @@ func TestJSONLBackend_SummarizeFlow(t *testing.T) {
t.Errorf("first message = %q, want %q", history[0].Content, "msg 16")
}
}
+
+func TestJSONLBackend_ResolveAliasAndPersistMetadata(t *testing.T) {
+ b := newBackend(t)
+
+ scope := &session.SessionScope{
+ Version: session.ScopeVersionV1,
+ AgentID: "main",
+ Channel: "telegram",
+ Account: "default",
+ Dimensions: []string{"chat"},
+ Values: map[string]string{
+ "chat": "group:c1",
+ },
+ }
+ b.EnsureSessionMetadata("canonical", scope, []string{"legacy"})
+
+ if got := b.ResolveSessionKey("legacy"); got != "canonical" {
+ t.Fatalf("ResolveSessionKey() = %q, want %q", got, "canonical")
+ }
+
+ b.AddMessage("legacy", "user", "hello through alias")
+ history := b.GetHistory("canonical")
+ if len(history) != 1 {
+ t.Fatalf("len(history) = %d, want 1", len(history))
+ }
+ if history[0].Content != "hello through alias" {
+ t.Fatalf("history[0].Content = %q, want %q", history[0].Content, "hello through alias")
+ }
+
+ resolvedScope := b.GetSessionScope("legacy")
+ if resolvedScope == nil {
+ t.Fatal("GetSessionScope() returned nil")
+ }
+ if resolvedScope.AgentID != scope.AgentID || resolvedScope.Values["chat"] != scope.Values["chat"] {
+ t.Fatalf("GetSessionScope() = %+v, want %+v", resolvedScope, scope)
+ }
+}
+
+func TestJSONLBackend_EnsureSessionMetadata_PromotesLegacyAliasHistory(t *testing.T) {
+ b := newBackend(t)
+
+ legacyKey := "agent:main:direct:legacy-user"
+ b.AddMessage(legacyKey, "user", "legacy history")
+ b.SetSummary(legacyKey, "legacy summary")
+
+ canonicalKey := session.BuildOpaqueSessionKey(legacyKey)
+ b.EnsureSessionMetadata(canonicalKey, &session.SessionScope{
+ Version: session.ScopeVersionV1,
+ AgentID: "main",
+ }, []string{legacyKey})
+
+ if got := b.ResolveSessionKey(legacyKey); got != canonicalKey {
+ t.Fatalf("ResolveSessionKey() = %q, want %q", got, canonicalKey)
+ }
+ history := b.GetHistory(canonicalKey)
+ if len(history) != 1 || history[0].Content != "legacy history" {
+ t.Fatalf("promoted history = %+v", history)
+ }
+ if summary := b.GetSummary(canonicalKey); summary != "legacy summary" {
+ t.Fatalf("promoted summary = %q, want %q", summary, "legacy summary")
+ }
+}
+
+func TestJSONLBackend_EnsureSessionMetadata_PromotesLegacyPicoDirectAliasHistory(t *testing.T) {
+ b := newBackend(t)
+
+ legacyKey := "agent:main:pico:direct:pico:session-123"
+ b.AddMessage(legacyKey, "user", "legacy pico history")
+
+ scope := &session.SessionScope{
+ Version: session.ScopeVersionV1,
+ AgentID: "main",
+ Channel: "pico",
+ Account: "default",
+ Dimensions: []string{"sender"},
+ Values: map[string]string{
+ "sender": "pico-user",
+ },
+ }
+ allocation := session.AllocateRouteSession(session.AllocationInput{
+ AgentID: "main",
+ Context: bus.InboundContext{
+ Channel: "pico",
+ Account: "default",
+ ChatID: "pico:session-123",
+ ChatType: "direct",
+ SenderID: "pico-user",
+ },
+ SessionPolicy: routing.SessionPolicy{
+ Dimensions: []string{"sender"},
+ },
+ })
+
+ b.EnsureSessionMetadata(allocation.SessionKey, scope, allocation.SessionAliases)
+
+ if got := b.ResolveSessionKey(legacyKey); got != allocation.SessionKey {
+ t.Fatalf("ResolveSessionKey() = %q, want %q", got, allocation.SessionKey)
+ }
+ history := b.GetHistory(allocation.SessionKey)
+ if len(history) != 1 || history[0].Content != "legacy pico history" {
+ t.Fatalf("promoted history = %+v", history)
+ }
+}
+
+func TestJSONLBackend_EnsureSessionMetadata_DoesNotOverwriteNonEmptyCanonicalHistory(t *testing.T) {
+ b := newBackend(t)
+
+ canonicalKey := session.BuildOpaqueSessionKey("agent:main:direct:current-user")
+ legacyKey := "agent:main:direct:legacy-user"
+
+ b.AddMessage(canonicalKey, "user", "current canonical history")
+ b.AddMessage(legacyKey, "user", "legacy history")
+
+ b.EnsureSessionMetadata(canonicalKey, &session.SessionScope{
+ Version: session.ScopeVersionV1,
+ AgentID: "main",
+ }, []string{legacyKey})
+
+ history := b.GetHistory(canonicalKey)
+ if len(history) != 1 || history[0].Content != "current canonical history" {
+ t.Fatalf("canonical history overwritten: %+v", history)
+ }
+}
diff --git a/pkg/session/key.go b/pkg/session/key.go
new file mode 100644
index 000000000..fb0836bc1
--- /dev/null
+++ b/pkg/session/key.go
@@ -0,0 +1,205 @@
+package session
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/routing"
+)
+
+const (
+ sessionKeyV1Prefix = "sk_v1_"
+ legacyAgentSessionKeyPrefix = "agent:"
+)
+
+type ParsedLegacySessionKey struct {
+ AgentID string
+ Rest string
+}
+
+// BuildOpaqueSessionKey returns a stable opaque session key derived from a
+// canonical alias string. The alias remains available through metadata for
+// compatibility and migration purposes.
+func BuildOpaqueSessionKey(alias string) string {
+ normalized := strings.TrimSpace(strings.ToLower(alias))
+ if normalized == "" {
+ return ""
+ }
+ sum := sha256.Sum256([]byte(normalized))
+ return sessionKeyV1Prefix + hex.EncodeToString(sum[:])
+}
+
+// IsOpaqueSessionKey returns true when the key matches the current opaque
+// session-key format.
+func IsOpaqueSessionKey(key string) bool {
+ return strings.HasPrefix(strings.ToLower(strings.TrimSpace(key)), sessionKeyV1Prefix)
+}
+
+func IsLegacyAgentSessionKey(key string) bool {
+ return strings.HasPrefix(strings.ToLower(strings.TrimSpace(key)), legacyAgentSessionKeyPrefix)
+}
+
+func IsExplicitSessionKey(key string) bool {
+ return IsOpaqueSessionKey(key) || IsLegacyAgentSessionKey(key)
+}
+
+func ParseLegacyAgentSessionKey(sessionKey string) *ParsedLegacySessionKey {
+ raw := strings.TrimSpace(sessionKey)
+ if raw == "" {
+ return nil
+ }
+ parts := strings.SplitN(raw, ":", 3)
+ if len(parts) < 3 || parts[0] != "agent" {
+ return nil
+ }
+ agentID := strings.TrimSpace(parts[1])
+ rest := parts[2]
+ if agentID == "" || rest == "" {
+ return nil
+ }
+ return &ParsedLegacySessionKey{AgentID: agentID, Rest: rest}
+}
+
+// ResolveAgentID returns the routed agent ID associated with a session. It
+// prefers structured session scope metadata when available and falls back to
+// legacy agent-scoped session keys for compatibility.
+func ResolveAgentID(store any, sessionKey string) string {
+ if scopeReader, ok := store.(interface {
+ GetSessionScope(sessionKey string) *SessionScope
+ }); ok {
+ scope := scopeReader.GetSessionScope(sessionKey)
+ if scope != nil && strings.TrimSpace(scope.AgentID) != "" {
+ return routing.NormalizeAgentID(scope.AgentID)
+ }
+ }
+
+ if parsed := ParseLegacyAgentSessionKey(sessionKey); parsed != nil {
+ return routing.NormalizeAgentID(parsed.AgentID)
+ }
+
+ return ""
+}
+
+func BuildLegacyMainAlias(agentID string) string {
+ return fmt.Sprintf("agent:%s:main", routing.NormalizeAgentID(agentID))
+}
+
+// BuildMainSessionKey returns the canonical opaque main-session key for an
+// agent. The corresponding legacy alias remains available via
+// BuildLegacyMainAlias for compatibility and migration logic.
+func BuildMainSessionKey(agentID string) string {
+ return BuildOpaqueSessionKey(BuildLegacyMainAlias(agentID))
+}
+
+func BuildLegacyDirectAliases(agentID, channel, account, peerID string) []string {
+ agentID = routing.NormalizeAgentID(agentID)
+ channel = normalizeLegacyChannel(channel)
+ account = routing.NormalizeAccountID(account)
+ peerID = strings.ToLower(strings.TrimSpace(peerID))
+ if peerID == "" {
+ return nil
+ }
+ return []string{
+ fmt.Sprintf("agent:%s:direct:%s", agentID, peerID),
+ fmt.Sprintf("agent:%s:%s:direct:%s", agentID, channel, peerID),
+ fmt.Sprintf("agent:%s:%s:%s:direct:%s", agentID, channel, account, peerID),
+ }
+}
+
+func BuildLegacyPeerAlias(agentID, channel, peerKind, peerID string) string {
+ agentID = routing.NormalizeAgentID(agentID)
+ channel = normalizeLegacyChannel(channel)
+ peerKind = strings.ToLower(strings.TrimSpace(peerKind))
+ if peerKind == "" {
+ peerKind = "unknown"
+ }
+ peerID = strings.ToLower(strings.TrimSpace(peerID))
+ if peerID == "" {
+ peerID = "unknown"
+ }
+ return fmt.Sprintf("agent:%s:%s:%s:%s", agentID, channel, peerKind, peerID)
+}
+
+// CanonicalSessionIdentityID collapses an identity using identity_links when
+// possible, then returns a normalized lowercase identifier.
+func CanonicalSessionIdentityID(channel, rawID string, identityLinks map[string][]string) string {
+ normalizedID := strings.TrimSpace(rawID)
+ if normalizedID == "" {
+ return ""
+ }
+ if linked := resolveLinkedPeerID(identityLinks, channel, normalizedID); linked != "" {
+ normalizedID = linked
+ }
+ return strings.ToLower(normalizedID)
+}
+
+func normalizeLegacyChannel(channel string) string {
+ channel = strings.ToLower(strings.TrimSpace(channel))
+ if channel == "" {
+ return "unknown"
+ }
+ return channel
+}
+
+func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID string) string {
+ if len(identityLinks) == 0 {
+ return ""
+ }
+ peerID = strings.TrimSpace(peerID)
+ if peerID == "" {
+ return ""
+ }
+
+ candidates := make(map[string]bool)
+ rawCandidate := strings.ToLower(peerID)
+ if rawCandidate != "" {
+ candidates[rawCandidate] = true
+ }
+ channel = strings.ToLower(strings.TrimSpace(channel))
+ if channel != "" {
+ candidates[fmt.Sprintf("%s:%s", channel, rawCandidate)] = true
+ }
+ if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 {
+ candidates[rawCandidate[idx+1:]] = true
+ }
+
+ for canonical, ids := range identityLinks {
+ canonicalName := strings.TrimSpace(canonical)
+ if canonicalName == "" {
+ continue
+ }
+ for _, id := range ids {
+ normalized := strings.ToLower(strings.TrimSpace(id))
+ if normalized != "" && candidates[normalized] {
+ return canonicalName
+ }
+ }
+ }
+ return ""
+}
+
+// CanonicalScopeSignature returns a stable serialized representation of scope.
+func CanonicalScopeSignature(scope SessionScope) string {
+ parts := []string{
+ fmt.Sprintf("v=%d", scope.Version),
+ fmt.Sprintf("agent=%s", strings.TrimSpace(strings.ToLower(scope.AgentID))),
+ fmt.Sprintf("channel=%s", strings.TrimSpace(strings.ToLower(scope.Channel))),
+ fmt.Sprintf("account=%s", strings.TrimSpace(strings.ToLower(scope.Account))),
+ }
+ for _, dimension := range scope.Dimensions {
+ dimension = strings.TrimSpace(strings.ToLower(dimension))
+ if dimension == "" {
+ continue
+ }
+ value := strings.TrimSpace(strings.ToLower(scope.Values[dimension]))
+ parts = append(parts, fmt.Sprintf("%s=%s", dimension, value))
+ }
+ return strings.Join(parts, "|")
+}
+
+// BuildSessionKey returns the current opaque key for a structured session scope.
+func BuildSessionKey(scope SessionScope) string {
+ return BuildOpaqueSessionKey(CanonicalScopeSignature(scope))
+}
diff --git a/pkg/session/key_test.go b/pkg/session/key_test.go
new file mode 100644
index 000000000..6cdf397e1
--- /dev/null
+++ b/pkg/session/key_test.go
@@ -0,0 +1,100 @@
+package session
+
+import "testing"
+
+type testScopeReader struct {
+ scope *SessionScope
+}
+
+func (r testScopeReader) GetSessionScope(sessionKey string) *SessionScope {
+ return CloneScope(r.scope)
+}
+
+func TestIsExplicitSessionKey(t *testing.T) {
+ tests := []struct {
+ key string
+ want bool
+ }{
+ {"sk_v1_abc", true},
+ {"agent:main:direct:user123", true},
+ {"custom-key", false},
+ {"", false},
+ }
+
+ for _, tt := range tests {
+ if got := IsExplicitSessionKey(tt.key); got != tt.want {
+ t.Fatalf("IsExplicitSessionKey(%q) = %v, want %v", tt.key, got, tt.want)
+ }
+ }
+}
+
+func TestParseLegacyAgentSessionKey(t *testing.T) {
+ parsed := ParseLegacyAgentSessionKey("agent:sales:telegram:direct:user123")
+ if parsed == nil {
+ t.Fatal("expected parsed legacy key, got nil")
+ }
+ if parsed.AgentID != "sales" {
+ t.Fatalf("AgentID = %q, want sales", parsed.AgentID)
+ }
+ if parsed.Rest != "telegram:direct:user123" {
+ t.Fatalf("Rest = %q, want telegram:direct:user123", parsed.Rest)
+ }
+
+ if got := ParseLegacyAgentSessionKey("sk_v1_abc"); got != nil {
+ t.Fatalf("expected nil for opaque key, got %+v", got)
+ }
+}
+
+func TestBuildLegacyDirectAliases(t *testing.T) {
+ aliases := BuildLegacyDirectAliases("Main", "Telegram", "BotA", "User123")
+ want := []string{
+ "agent:main:direct:user123",
+ "agent:main:telegram:direct:user123",
+ "agent:main:telegram:bota:direct:user123",
+ }
+ if len(aliases) != len(want) {
+ t.Fatalf("len(aliases) = %d, want %d", len(aliases), len(want))
+ }
+ for i := range want {
+ if aliases[i] != want[i] {
+ t.Fatalf("aliases[%d] = %q, want %q", i, aliases[i], want[i])
+ }
+ }
+}
+
+func TestBuildLegacyPeerAlias(t *testing.T) {
+ got := BuildLegacyPeerAlias("Main", "Slack", "channel", "C001")
+ if got != "agent:main:slack:channel:c001" {
+ t.Fatalf("BuildLegacyPeerAlias() = %q", got)
+ }
+}
+
+func TestBuildMainSessionKey(t *testing.T) {
+ got := BuildMainSessionKey("Main")
+ if !IsOpaqueSessionKey(got) {
+ t.Fatalf("BuildMainSessionKey() = %q, want opaque key", got)
+ }
+ if got != BuildOpaqueSessionKey("agent:main:main") {
+ t.Fatalf("BuildMainSessionKey() = %q, want stable main-key hash", got)
+ }
+}
+
+func TestResolveAgentID_PrefersSessionScope(t *testing.T) {
+ store := testScopeReader{
+ scope: &SessionScope{
+ Version: ScopeVersionV1,
+ AgentID: "Support",
+ Channel: "slack",
+ },
+ }
+
+ if got := ResolveAgentID(store, "sk_v1_anything"); got != "support" {
+ t.Fatalf("ResolveAgentID() = %q, want support", got)
+ }
+}
+
+func TestResolveAgentID_FallsBackToLegacyKey(t *testing.T) {
+ if got := ResolveAgentID(nil, "agent:Sales:telegram:direct:user123"); got != "sales" {
+ t.Fatalf("ResolveAgentID() = %q, want sales", got)
+ }
+}
diff --git a/pkg/session/manager.go b/pkg/session/manager.go
index ef720b7c5..1d6fa3106 100644
--- a/pkg/session/manager.go
+++ b/pkg/session/manager.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/providers/messageutil"
)
type Session struct {
@@ -69,6 +70,10 @@ func (sm *SessionManager) AddMessage(sessionKey, role, content string) {
// AddFullMessage adds a complete message with tool calls and tool call ID to the session.
// This is used to save the full conversation flow including tool calls and tool results.
func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Message) {
+ if messageutil.IsTransientAssistantThoughtMessage(msg) {
+ return
+ }
+
sm.mu.Lock()
defer sm.mu.Unlock()
@@ -145,6 +150,16 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) {
session.Updated = time.Now()
}
+func (sm *SessionManager) ListSessions() []string {
+ sm.mu.RLock()
+ defer sm.mu.RUnlock()
+ keys := make([]string, 0, len(sm.sessions))
+ for k := range sm.sessions {
+ keys = append(keys, k)
+ }
+ return keys
+}
+
// sanitizeFilename converts a session key into a cross-platform safe filename.
// Replaces ':' with '_' (session key separator) and '/' and '\' with '_' so
// composite IDs (e.g. Telegram forum "chatID/threadID") do not create
@@ -186,8 +201,7 @@ func (sm *SessionManager) Save(key string) error {
Updated: stored.Updated,
}
if len(stored.Messages) > 0 {
- snapshot.Messages = make([]providers.Message, len(stored.Messages))
- copy(snapshot.Messages, stored.Messages)
+ snapshot.Messages = messageutil.FilterInvalidHistoryMessages(stored.Messages)
} else {
snapshot.Messages = []providers.Message{}
}
@@ -260,6 +274,7 @@ func (sm *SessionManager) loadSessions() error {
if err := json.Unmarshal(data, &session); err != nil {
continue
}
+ session.Messages = messageutil.FilterInvalidHistoryMessages(session.Messages)
sm.sessions[session.Key] = &session
}
@@ -280,6 +295,7 @@ func (sm *SessionManager) SetHistory(key string, history []providers.Message) {
session, ok := sm.sessions[key]
if ok {
+ history = messageutil.FilterInvalidHistoryMessages(history)
// Create a deep copy to strictly isolate internal state
// from the caller's slice.
msgs := make([]providers.Message, len(history))
diff --git a/pkg/session/scope.go b/pkg/session/scope.go
new file mode 100644
index 000000000..efb026ea3
--- /dev/null
+++ b/pkg/session/scope.go
@@ -0,0 +1,32 @@
+package session
+
+// ScopeVersionV1 is the first structured session-scope schema version.
+const ScopeVersionV1 = 1
+
+// SessionScope describes the semantic session partition selected for a turn.
+type SessionScope struct {
+ Version int `json:"version"`
+ AgentID string `json:"agent_id"`
+ Channel string `json:"channel"`
+ Account string `json:"account"`
+ Dimensions []string `json:"dimensions"`
+ Values map[string]string `json:"values"`
+}
+
+// CloneScope returns a deep copy of scope.
+func CloneScope(scope *SessionScope) *SessionScope {
+ if scope == nil {
+ return nil
+ }
+ cloned := *scope
+ if len(scope.Dimensions) > 0 {
+ cloned.Dimensions = append([]string(nil), scope.Dimensions...)
+ }
+ if len(scope.Values) > 0 {
+ cloned.Values = make(map[string]string, len(scope.Values))
+ for key, value := range scope.Values {
+ cloned.Values[key] = value
+ }
+ }
+ return &cloned
+}
diff --git a/pkg/session/session_store.go b/pkg/session/session_store.go
index 1d1a2f967..2ba2a974d 100644
--- a/pkg/session/session_store.go
+++ b/pkg/session/session_store.go
@@ -27,6 +27,8 @@ type SessionStore interface {
TruncateHistory(key string, keepLast int)
// Save persists any pending state to durable storage.
Save(key string) error
+ // ListSessions returns all known session keys.
+ ListSessions() []string
// Close releases resources held by the store.
Close() error
}
diff --git a/pkg/skills/clawhub_registry.go b/pkg/skills/clawhub_registry.go
index bd4bed8fb..677a57f18 100644
--- a/pkg/skills/clawhub_registry.go
+++ b/pkg/skills/clawhub_registry.go
@@ -5,11 +5,13 @@ import (
"encoding/json"
"fmt"
"io"
+ "log/slog"
"net/http"
"net/url"
"os"
"time"
+ "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/utils"
)
@@ -19,6 +21,35 @@ const (
defaultMaxResponseSize = 2 * 1024 * 1024 // 2 MB
)
+func init() {
+ RegisterRegistryProviderBuilder("clawhub", func(_ string, cfg config.SkillRegistryConfig) RegistryProvider {
+ privateCfg := clawHubRegistryPrivateConfig{}
+ if err := cfg.DecodeParam(&privateCfg); err != nil {
+ slog.Warn("invalid clawhub private config", "error", err)
+ }
+ return ClawHubConfig{
+ Enabled: cfg.Enabled,
+ BaseURL: cfg.BaseURL,
+ AuthToken: cfg.AuthToken.String(),
+ SearchPath: privateCfg.SearchPath,
+ SkillsPath: privateCfg.SkillsPath,
+ DownloadPath: privateCfg.DownloadPath,
+ Timeout: privateCfg.Timeout,
+ MaxZipSize: privateCfg.MaxZipSize,
+ MaxResponseSize: privateCfg.MaxResponseSize,
+ }
+ })
+}
+
+type clawHubRegistryPrivateConfig struct {
+ SearchPath string `json:"search_path"`
+ SkillsPath string `json:"skills_path"`
+ DownloadPath string `json:"download_path"`
+ Timeout int `json:"timeout"`
+ MaxZipSize int `json:"max_zip_size"`
+ MaxResponseSize int `json:"max_response_size"`
+}
+
// ClawHubRegistry implements SkillRegistry for the ClawHub platform.
type ClawHubRegistry struct {
baseURL string
@@ -88,6 +119,28 @@ func (c *ClawHubRegistry) Name() string {
return "clawhub"
}
+func (c *ClawHubRegistry) ResolveInstallDirName(target string) (string, error) {
+ if err := utils.ValidateSkillIdentifier(target); err != nil {
+ return "", err
+ }
+ return target, nil
+}
+
+func (c *ClawHubRegistry) SkillURL(slug, _ string) string {
+ if slug == "" {
+ return ""
+ }
+ return c.baseURL + "/skills/" + url.PathEscape(slug)
+}
+
+func (c ClawHubConfig) IsEnabled() bool {
+ return c.Enabled
+}
+
+func (c ClawHubConfig) BuildRegistry() SkillRegistry {
+ return NewClawHubRegistry(c)
+}
+
// --- Search ---
type clawhubSearchResponse struct {
diff --git a/pkg/skills/config_bridge.go b/pkg/skills/config_bridge.go
new file mode 100644
index 000000000..5302db196
--- /dev/null
+++ b/pkg/skills/config_bridge.go
@@ -0,0 +1,136 @@
+package skills
+
+import "github.com/sipeed/picoclaw/pkg/config"
+
+const defaultGitHubRegistryBaseURL = "https://github.com"
+
+func effectiveRegistryConfigsFromToolsConfig(cfg config.SkillsToolsConfig) []config.SkillRegistryConfig {
+ effective := make([]config.SkillRegistryConfig, 0, len(cfg.Registries)+1)
+ seen := map[string]struct{}{}
+
+ for _, registryCfg := range cfg.Registries {
+ if registryCfg == nil || registryCfg.Name == "" {
+ continue
+ }
+ resolved := *registryCfg
+ if resolved.Name == "github" {
+ resolved = applyLegacyGithubRegistryCompatibility(cfg, resolved)
+ }
+ effective = append(effective, resolved)
+ seen[resolved.Name] = struct{}{}
+ }
+
+ if _, ok := seen["github"]; ok {
+ return effective
+ }
+
+ legacyGithubConfigured := cfg.Github.BaseURL != "" || cfg.Github.Token.String() != "" || cfg.Github.Proxy != ""
+ if !legacyGithubConfigured {
+ return effective
+ }
+
+ effective = append(effective, applyLegacyGithubRegistryCompatibility(cfg, config.SkillRegistryConfig{
+ Name: "github",
+ Enabled: true,
+ }))
+ return effective
+}
+
+func applyLegacyGithubRegistryCompatibility(
+ cfg config.SkillsToolsConfig,
+ registryCfg config.SkillRegistryConfig,
+) config.SkillRegistryConfig {
+ if registryCfg.Name != "github" {
+ return registryCfg
+ }
+ if registryCfg.Param == nil {
+ registryCfg.Param = map[string]any{}
+ }
+ if registryCfg.BaseURL == "" ||
+ (registryCfg.BaseURL == defaultGitHubRegistryBaseURL &&
+ cfg.Github.BaseURL != "" &&
+ cfg.Github.BaseURL != defaultGitHubRegistryBaseURL) {
+ registryCfg.BaseURL = cfg.Github.BaseURL
+ }
+ if registryCfg.AuthToken.String() == "" {
+ registryCfg.AuthToken = cfg.Github.Token
+ }
+ if _, ok := registryCfg.Param["proxy"]; !ok && cfg.Github.Proxy != "" {
+ registryCfg.Param["proxy"] = cfg.Github.Proxy
+ }
+ return registryCfg
+}
+
+func registryProvidersFromToolsConfig(cfg config.SkillsToolsConfig) []RegistryProvider {
+ registryConfigs := effectiveRegistryConfigsFromToolsConfig(cfg)
+ providers := make([]RegistryProvider, 0, len(registryConfigs))
+ for _, registryCfg := range registryConfigs {
+ provider := buildRegistryProvider(registryCfg.Name, registryCfg)
+ if provider == nil {
+ continue
+ }
+ providers = append(providers, provider)
+ }
+ return providers
+}
+
+func NewRegistryManagerFromToolsConfig(cfg config.SkillsToolsConfig) *RegistryManager {
+ return NewRegistryManagerFromConfig(RegistryConfig{
+ Providers: registryProvidersFromToolsConfig(cfg),
+ MaxConcurrentSearches: cfg.MaxConcurrentSearches,
+ })
+}
+
+func LookupRegistryFromToolsConfig(cfg config.SkillsToolsConfig, name string) SkillRegistry {
+ for _, provider := range registryProvidersFromToolsConfig(cfg) {
+ if provider == nil {
+ continue
+ }
+ registry := provider.BuildRegistry()
+ if registry == nil || registry.Name() != name {
+ continue
+ }
+ return registry
+ }
+ return nil
+}
+
+func GitHubInstallDirNameFromToolsConfig(cfg config.SkillsToolsConfig, target string) (string, error) {
+ registryCfg, ok := cfg.Registries.Get("github")
+ if ok {
+ registryCfg = applyLegacyGithubRegistryCompatibility(cfg, registryCfg)
+ return githubInstallDirNameWithBaseURL(target, registryCfg.BaseURL)
+ }
+ return githubInstallDirNameWithBaseURL(target, cfg.Github.BaseURL)
+}
+
+func NormalizeInstallTargetForRegistry(cfg config.SkillsToolsConfig, registryName, target string) string {
+ if registryName == "" || target == "" {
+ return target
+ }
+ registry := LookupRegistryFromToolsConfig(cfg, registryName)
+ if registry == nil {
+ return target
+ }
+ ghRegistry, ok := registry.(*GitHubRegistry)
+ if !ok {
+ return target
+ }
+ normalized, err := canonicalGitHubRegistrySlugWithBaseURL(target, ghRegistry.webBase)
+ if err != nil || normalized == "" {
+ return target
+ }
+ return normalized
+}
+
+func BuildInstallMetadataForRegistryInstance(registry SkillRegistry, target, version string) (string, string) {
+ normalizedTarget := NormalizeInstallTargetForRegistryInstance(registry, target)
+ if registry == nil {
+ return normalizedTarget, ""
+ }
+ registryURL := registry.SkillURL(target, version)
+ if registryURL == "" {
+ registryURL = registry.SkillURL(normalizedTarget, version)
+ }
+ return normalizedTarget, registryURL
+}
diff --git a/pkg/skills/github_registry.go b/pkg/skills/github_registry.go
new file mode 100644
index 000000000..de2dd9697
--- /dev/null
+++ b/pkg/skills/github_registry.go
@@ -0,0 +1,305 @@
+package skills
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "path"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ RegisterRegistryProviderBuilder("github", func(_ string, cfg config.SkillRegistryConfig) RegistryProvider {
+ privateCfg := githubRegistryPrivateConfig{}
+ if err := cfg.DecodeParam(&privateCfg); err != nil {
+ slog.Warn("invalid github private config", "error", err)
+ }
+ return GitHubRegistryConfig{
+ Enabled: cfg.Enabled,
+ BaseURL: cfg.BaseURL,
+ AuthToken: cfg.AuthToken.String(),
+ Proxy: privateCfg.Proxy,
+ }
+ })
+}
+
+type githubRegistryPrivateConfig struct {
+ Proxy string `json:"proxy"`
+}
+
+type GitHubRegistryConfig struct {
+ Enabled bool
+ BaseURL string
+ AuthToken string
+ Proxy string
+}
+
+type GitHubRegistry struct {
+ installer *SkillInstaller
+ webBase string
+}
+
+const githubAuthTokenHelp = "configure registries.github.auth_token"
+
+func (c GitHubRegistryConfig) IsEnabled() bool {
+ return c.Enabled
+}
+
+func (c GitHubRegistryConfig) BuildRegistry() SkillRegistry {
+ installer, err := NewSkillInstallerWithBaseURL("", c.BaseURL, c.AuthToken, c.Proxy)
+ if err != nil {
+ slog.Warn("failed to create github registry installer", "error", err)
+ return nil
+ }
+ return &GitHubRegistry{
+ installer: installer,
+ webBase: installer.githubBaseURL,
+ }
+}
+
+func (r *GitHubRegistry) Name() string {
+ return "github"
+}
+
+func (r *GitHubRegistry) ResolveInstallDirName(target string) (string, error) {
+ return githubInstallDirNameWithBaseURL(target, r.webBase)
+}
+
+func (r *GitHubRegistry) NormalizeInstallTarget(target string) string {
+ normalized, err := canonicalGitHubRegistrySlugWithBaseURL(target, r.webBase)
+ if err != nil {
+ return target
+ }
+ return normalized
+}
+
+func (r *GitHubRegistry) SkillURL(target, version string) string {
+ defaultRef := strings.TrimSpace(version)
+ parsedTarget, err := parseGitHubTargetWithBaseURL(target, r.webBase, defaultRef)
+ if err != nil {
+ return ""
+ }
+ ref := parsedTarget.Ref
+ base := strings.TrimRight(parsedTarget.Endpoints.WebBaseURL, "/")
+ urlPath := path.Join(ref.Owner, ref.RepoName)
+ if ref.SubPath != "" {
+ if ref.Ref == "" {
+ return ""
+ }
+ viewKind := "tree"
+ if isSkillMarkdownPath(ref.SubPath) {
+ viewKind = "blob"
+ }
+ return fmt.Sprintf("%s/%s/%s/%s/%s", base, urlPath, viewKind, ref.Ref, ref.SubPath)
+ }
+ if ref.Ref == "" {
+ return fmt.Sprintf("%s/%s", base, urlPath)
+ }
+ if ref.Ref != "main" {
+ return fmt.Sprintf("%s/%s/tree/%s", base, urlPath, ref.Ref)
+ }
+ return fmt.Sprintf("%s/%s", base, urlPath)
+}
+
+type gitHubCodeSearchResponse struct {
+ Items []gitHubCodeSearchItem `json:"items"`
+}
+
+type gitHubCodeSearchItem struct {
+ Path string `json:"path"`
+ HTMLURL string `json:"html_url"`
+ Score float64 `json:"score"`
+ Repository struct {
+ FullName string `json:"full_name"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ DefaultBranch string `json:"default_branch"`
+ } `json:"repository"`
+}
+
+func (r *GitHubRegistry) Search(ctx context.Context, query string, limit int) ([]SearchResult, error) {
+ query = strings.TrimSpace(query)
+ if query == "" {
+ return nil, nil
+ }
+ if limit <= 0 {
+ limit = 5
+ }
+
+ u, err := url.Parse(strings.TrimRight(r.installer.githubAPIBaseURL, "/") + "/search/code")
+ if err != nil {
+ return nil, fmt.Errorf("invalid github api base url: %w", err)
+ }
+ q := u.Query()
+ q.Set("q", fmt.Sprintf("%s filename:SKILL.md", query))
+ q.Set("per_page", fmt.Sprintf("%d", limit))
+ u.RawQuery = q.Encode()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/vnd.github+json")
+ if r.installer.githubToken != "" {
+ req.Header.Set("Authorization", "Bearer "+r.installer.githubToken)
+ }
+
+ resp, err := r.installer.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
+ if err != nil {
+ return nil, fmt.Errorf("failed to read github search response: %w", err)
+ }
+ if resp.StatusCode == http.StatusUnauthorized && r.installer.githubToken == "" && isGitHubAuthRequiredError(body) {
+ slog.Warn("github search requires authentication; returning no results", "help", githubAuthTokenHelp)
+ return []SearchResult{}, nil
+ }
+ if resp.StatusCode == http.StatusForbidden && r.installer.githubToken == "" && isGitHubRateLimitError(body) {
+ slog.Warn("github search hit unauthenticated rate limit; returning no results", "help", githubAuthTokenHelp)
+ return []SearchResult{}, nil
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("github search failed: HTTP %d: %s", resp.StatusCode, string(body))
+ }
+
+ var parsed gitHubCodeSearchResponse
+ if err := json.Unmarshal(body, &parsed); err != nil {
+ return nil, fmt.Errorf("failed to parse github search response: %w", err)
+ }
+
+ resultsBySlug := map[string]SearchResult{}
+ for _, item := range parsed.Items {
+ slug, ok := githubSearchSlug(item)
+ if !ok {
+ continue
+ }
+ result := SearchResult{
+ Score: item.Score,
+ Slug: slug,
+ DisplayName: githubSearchDisplayName(item),
+ Summary: strings.TrimSpace(item.Repository.Description),
+ Version: strings.TrimSpace(item.Repository.DefaultBranch),
+ RegistryName: r.Name(),
+ }
+ if existing, exists := resultsBySlug[slug]; exists && existing.Score >= result.Score {
+ continue
+ }
+ resultsBySlug[slug] = result
+ }
+
+ results := make([]SearchResult, 0, len(resultsBySlug))
+ for _, result := range resultsBySlug {
+ results = append(results, result)
+ }
+ sort.Slice(results, func(i, j int) bool {
+ if results[i].Score == results[j].Score {
+ return results[i].Slug < results[j].Slug
+ }
+ return results[i].Score > results[j].Score
+ })
+ if len(results) > limit {
+ results = results[:limit]
+ }
+ return results, nil
+}
+
+func isGitHubRateLimitError(body []byte) bool {
+ message := strings.ToLower(string(body))
+ return strings.Contains(message, "rate limit exceeded")
+}
+
+func isGitHubAuthRequiredError(body []byte) bool {
+ message := strings.ToLower(string(body))
+ return strings.Contains(message, "requires authentication") ||
+ strings.Contains(message, "must be authenticated to access the code search api")
+}
+
+func githubSearchSlug(item gitHubCodeSearchItem) (string, bool) {
+ fullName := strings.TrimSpace(item.Repository.FullName)
+ if fullName == "" {
+ return "", false
+ }
+ cleanPath := strings.Trim(strings.TrimSpace(item.Path), "/")
+ if cleanPath == "" || filepath.Base(cleanPath) != "SKILL.md" {
+ return "", false
+ }
+ dir := path.Dir(cleanPath)
+ if dir == "." || dir == "" {
+ return fullName, true
+ }
+ return fullName + "/" + dir, true
+}
+
+func githubSearchDisplayName(item gitHubCodeSearchItem) string {
+ cleanPath := strings.Trim(strings.TrimSpace(item.Path), "/")
+ if cleanPath != "" {
+ dir := path.Dir(cleanPath)
+ if dir != "." && dir != "" {
+ return path.Base(dir)
+ }
+ }
+ if name := strings.TrimSpace(item.Repository.Name); name != "" {
+ return name
+ }
+ return strings.TrimSpace(item.Repository.FullName)
+}
+
+func canonicalGitHubRegistrySlugWithBaseURL(target, githubBaseURL string) (string, error) {
+ ref, err := parseGitHubRefWithBaseURL(target, githubBaseURL, "")
+ if err != nil {
+ return "", err
+ }
+ slug := path.Join(ref.Owner, ref.RepoName)
+ if ref.SubPath != "" {
+ slug = path.Join(slug, ref.SubPath)
+ }
+ return slug, nil
+}
+
+func (r *GitHubRegistry) GetSkillMeta(ctx context.Context, target string) (*SkillMeta, error) {
+ slug, err := canonicalGitHubRegistrySlugWithBaseURL(target, r.webBase)
+ if err != nil {
+ return nil, err
+ }
+ parsedTarget, err := parseGitHubTargetWithBaseURL(target, r.webBase, "")
+ if err != nil {
+ return nil, err
+ }
+ ref := parsedTarget.Ref
+ if ref.Ref == "" {
+ ref.Ref, err = r.installer.fetchDefaultBranchWithAPIBaseURL(
+ ctx,
+ parsedTarget.Endpoints.APIBaseURL,
+ ref.Owner,
+ ref.RepoName,
+ )
+ if err != nil {
+ return nil, err
+ }
+ }
+ return &SkillMeta{
+ Slug: slug,
+ DisplayName: ref.RepoName,
+ LatestVersion: ref.Ref,
+ RegistryName: r.Name(),
+ }, nil
+}
+
+func (r *GitHubRegistry) DownloadAndInstall(
+ ctx context.Context,
+ target, version, targetDir string,
+) (*InstallResult, error) {
+ return r.installer.InstallFromGitHubToDir(ctx, target, version, targetDir)
+}
diff --git a/pkg/skills/github_registry_test.go b/pkg/skills/github_registry_test.go
new file mode 100644
index 000000000..3ac309700
--- /dev/null
+++ b/pkg/skills/github_registry_test.go
@@ -0,0 +1,218 @@
+package skills
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestGitHubRegistrySearch(t *testing.T) {
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/api/v3/search/code", r.URL.Path)
+ assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
+ assert.Equal(t, "skill search filename:SKILL.md", r.URL.Query().Get("q"))
+ assert.Equal(t, "2", r.URL.Query().Get("per_page"))
+
+ w.Header().Set("Content-Type", "application/json")
+ require.NoError(t, json.NewEncoder(w).Encode(gitHubCodeSearchResponse{
+ Items: []gitHubCodeSearchItem{
+ {
+ Path: "skills/pr-review/SKILL.md",
+ Score: 10,
+ HTMLURL: server.URL + "/foo/bar/blob/main/skills/pr-review/SKILL.md",
+ Repository: struct {
+ FullName string `json:"full_name"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ DefaultBranch string `json:"default_branch"`
+ }{
+ FullName: "foo/bar",
+ Name: "bar",
+ Description: "Review pull requests",
+ DefaultBranch: "main",
+ },
+ },
+ {
+ Path: "SKILL.md",
+ Score: 5,
+ HTMLURL: server.URL + "/foo/root/blob/main/SKILL.md",
+ Repository: struct {
+ FullName string `json:"full_name"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ DefaultBranch string `json:"default_branch"`
+ }{
+ FullName: "foo/root",
+ Name: "root",
+ Description: "Root skill",
+ DefaultBranch: "master",
+ },
+ },
+ },
+ }))
+ }))
+ defer server.Close()
+
+ provider := GitHubRegistryConfig{
+ Enabled: true,
+ BaseURL: server.URL,
+ AuthToken: "test-token",
+ }
+ registry := provider.BuildRegistry()
+ require.NotNil(t, registry)
+
+ results, err := registry.Search(context.Background(), "skill search", 2)
+ require.NoError(t, err)
+ require.Len(t, results, 2)
+
+ assert.Equal(t, "foo/bar/skills/pr-review", results[0].Slug)
+ assert.Equal(t, "pr-review", results[0].DisplayName)
+ assert.Equal(t, "Review pull requests", results[0].Summary)
+ assert.Equal(t, "main", results[0].Version)
+ assert.Equal(t, "github", results[0].RegistryName)
+
+ assert.Equal(t, "foo/root", results[1].Slug)
+ assert.Equal(t, "root", results[1].DisplayName)
+ assert.Equal(t, "master", results[1].Version)
+}
+
+func TestGitHubRegistryProviderDecodesProxyParam(t *testing.T) {
+ builder := buildRegistryProvider("github", config.SkillRegistryConfig{
+ Name: "github",
+ Enabled: true,
+ BaseURL: "https://github.com",
+ AuthToken: *config.NewSecureString("test-token"),
+ Param: map[string]any{
+ "proxy": "http://127.0.0.1:7890",
+ },
+ })
+ require.NotNil(t, builder)
+
+ registry := builder.BuildRegistry()
+ require.NotNil(t, registry)
+ ghRegistry, ok := registry.(*GitHubRegistry)
+ require.True(t, ok)
+ assert.Equal(t, "http://127.0.0.1:7890", ghRegistry.installer.proxy)
+}
+
+func TestGitHubRegistrySearchReturnsNoResultsOnUnauthenticatedRateLimit(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Empty(t, r.Header.Get("Authorization"))
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte(`{"message":"API rate limit exceeded for 1.2.3.4"}`))
+ }))
+ defer server.Close()
+
+ registry := GitHubRegistryConfig{Enabled: true, BaseURL: server.URL}.BuildRegistry()
+ require.NotNil(t, registry)
+
+ results, err := registry.Search(context.Background(), "pr review", 5)
+ require.NoError(t, err)
+ assert.Empty(t, results)
+}
+
+func TestGitHubRegistrySearchReturnsNoResultsOnUnauthenticatedAuthRequired(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Empty(t, r.Header.Get("Authorization"))
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(
+ `{"message":"Requires authentication","errors":[{"message":"Must be authenticated to access the code search API"}]}`,
+ ))
+ }))
+ defer server.Close()
+
+ registry := GitHubRegistryConfig{Enabled: true, BaseURL: server.URL}.BuildRegistry()
+ require.NotNil(t, registry)
+
+ results, err := registry.Search(context.Background(), "pr review", 5)
+ require.NoError(t, err)
+ assert.Empty(t, results)
+}
+
+func TestGitHubRegistryGetSkillMetaCanonicalizesURLSlug(t *testing.T) {
+ registry := GitHubRegistryConfig{
+ Enabled: true,
+ BaseURL: "https://ghe.example.com/git",
+ }.BuildRegistry()
+ require.NotNil(t, registry)
+
+ meta, err := registry.GetSkillMeta(
+ context.Background(),
+ "https://ghe.example.com/git/org/repo/tree/dev/skills/pr-review",
+ )
+ require.NoError(t, err)
+ require.NotNil(t, meta)
+ assert.Equal(t, "org/repo/skills/pr-review", meta.Slug)
+ assert.Equal(t, "dev", meta.LatestVersion)
+}
+
+func TestGitHubRegistrySkillURLUsesProvidedVersionAndBasePath(t *testing.T) {
+ registry := GitHubRegistryConfig{
+ Enabled: true,
+ BaseURL: "https://ghe.example.com/git",
+ }.BuildRegistry()
+ require.NotNil(t, registry)
+
+ assert.Equal(
+ t,
+ "https://ghe.example.com/git/org/repo/tree/master/skills/pr-review",
+ registry.SkillURL("org/repo/skills/pr-review", "master"),
+ )
+ assert.Equal(
+ t,
+ "https://ghe.example.com/git/org/repo/tree/dev/skills/pr-review",
+ registry.SkillURL("https://ghe.example.com/git/org/repo/tree/dev/skills/pr-review", ""),
+ )
+ assert.Equal(
+ t,
+ "https://ghe.example.com/git/org/repo/tree/feature/skills-registry/skills/pr-review",
+ registry.SkillURL("org/repo/skills/pr-review", "feature/skills-registry"),
+ )
+ assert.Equal(
+ t,
+ "https://ghe.example.com/git/org/repo/blob/main/.agents/skills/pr-review/SKILL.md",
+ registry.SkillURL("https://ghe.example.com/git/org/repo/blob/main/.agents/skills/pr-review/SKILL.md", ""),
+ )
+ assert.Equal(
+ t,
+ "https://github.com/org/repo/tree/main/.agents/skills/pr-review",
+ registry.SkillURL("https://github.com/org/repo/tree/main/.agents/skills/pr-review", ""),
+ )
+ assert.Empty(t, registry.SkillURL("org/repo/.agents/skills/pr-review", ""))
+}
+
+func TestGitHubRegistryResolveInstallDirNameSupportsFullURLs(t *testing.T) {
+ registry := GitHubRegistryConfig{
+ Enabled: true,
+ BaseURL: "https://ghe.example.com/git",
+ }.BuildRegistry()
+ require.NotNil(t, registry)
+
+ dirName, err := registry.ResolveInstallDirName("https://ghe.example.com/git/org/repo/tree/dev/skills/pr-review")
+ require.NoError(t, err)
+ assert.Equal(t, "pr-review", dirName)
+
+ dirName, err = registry.ResolveInstallDirName("https://github.com/org/repo/tree/main/skills/release-checklist")
+ require.NoError(t, err)
+ assert.Equal(t, "release-checklist", dirName)
+
+ dirName, err = registry.ResolveInstallDirName(
+ "https://ghe.example.com/git/org/repo/blob/dev/skills/pr-review/SKILL.md",
+ )
+ require.NoError(t, err)
+ assert.Equal(t, "pr-review", dirName)
+
+ dirName, err = registry.ResolveInstallDirName(
+ "https://ghe.example.com/git/org/repo/blob/dev/SKILL.md",
+ )
+ require.NoError(t, err)
+ assert.Equal(t, "repo", dirName)
+}
diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go
index f6cdee3a6..2f97ca8bf 100644
--- a/pkg/skills/installer.go
+++ b/pkg/skills/installer.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "io"
"net/http"
"net/url"
"os"
@@ -12,6 +13,7 @@ import (
"strings"
"time"
+ "github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/utils"
)
@@ -32,110 +34,434 @@ type GitHubRef struct {
SubPath string // Path within the repository
}
+type gitHubTarget struct {
+ Ref GitHubRef
+ Endpoints gitHubEndpoints
+}
+
type SkillInstaller struct {
- workspace string
- client *http.Client
- githubToken string
- proxy string
+ workspace string
+ client *http.Client
+ githubBaseURL string
+ githubAPIBaseURL string
+ githubRawBaseURL string
+ githubToken string
+ proxy string
}
// NewSkillInstaller creates a new skill installer.
// proxy is an optional HTTP/HTTPS/SOCKS5 proxy URL for downloading skills.
func NewSkillInstaller(workspace, githubToken, proxy string) (*SkillInstaller, error) {
+ return NewSkillInstallerWithBaseURL(workspace, "", githubToken, proxy)
+}
+
+// NewSkillInstallerWithBaseURL creates a new skill installer with a custom GitHub base URL.
+// For github.com this can be left empty. For GitHub Enterprise, set it to the web URL.
+func NewSkillInstallerWithBaseURL(workspace, githubBaseURL, githubToken, proxy string) (*SkillInstaller, error) {
client, err := utils.CreateHTTPClient(proxy, 15*time.Second)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
}
+ endpoints, err := resolveGitHubEndpoints(githubBaseURL)
+ if err != nil {
+ return nil, err
+ }
return &SkillInstaller{
- workspace: workspace,
- client: client,
- githubToken: githubToken,
- proxy: proxy,
+ workspace: workspace,
+ client: client,
+ githubBaseURL: endpoints.WebBaseURL,
+ githubAPIBaseURL: endpoints.APIBaseURL,
+ githubRawBaseURL: endpoints.RawBaseURL,
+ githubToken: githubToken,
+ proxy: proxy,
}, nil
}
+type gitHubEndpoints struct {
+ WebBaseURL string
+ APIBaseURL string
+ RawBaseURL string
+}
+
+func resolveGitHubEndpoints(baseURL string) (gitHubEndpoints, error) {
+ trimmed := strings.TrimSpace(baseURL)
+ if trimmed == "" {
+ return gitHubEndpoints{
+ WebBaseURL: "https://github.com",
+ APIBaseURL: "https://api.github.com",
+ RawBaseURL: "https://raw.githubusercontent.com",
+ }, nil
+ }
+
+ u, err := url.Parse(trimmed)
+ if err != nil {
+ return gitHubEndpoints{}, fmt.Errorf("invalid github base url: %w", err)
+ }
+ if u.Scheme == "" || u.Host == "" {
+ return gitHubEndpoints{}, fmt.Errorf("invalid github base url %q", baseURL)
+ }
+
+ trimmedPath := strings.TrimSuffix(u.Path, "/")
+ origin := u.Scheme + "://" + u.Host
+
+ if u.Host == "api.github.com" {
+ return gitHubEndpoints{
+ WebBaseURL: "https://github.com",
+ APIBaseURL: "https://api.github.com",
+ RawBaseURL: "https://raw.githubusercontent.com",
+ }, nil
+ }
+
+ if strings.HasSuffix(trimmedPath, "/api/v3") {
+ webBaseURL := origin + strings.TrimSuffix(trimmedPath, "/api/v3")
+ webBaseURL = strings.TrimSuffix(webBaseURL, "/")
+ if webBaseURL == origin {
+ webBaseURL = origin
+ }
+ return gitHubEndpoints{
+ WebBaseURL: webBaseURL,
+ APIBaseURL: origin + trimmedPath,
+ RawBaseURL: webBaseURL + "/raw",
+ }, nil
+ }
+
+ webBaseURL := origin + trimmedPath
+ webBaseURL = strings.TrimSuffix(webBaseURL, "/")
+ if u.Host == "github.com" {
+ return gitHubEndpoints{
+ WebBaseURL: "https://github.com",
+ APIBaseURL: "https://api.github.com",
+ RawBaseURL: "https://raw.githubusercontent.com",
+ }, nil
+ }
+
+ return gitHubEndpoints{
+ WebBaseURL: webBaseURL,
+ APIBaseURL: webBaseURL + "/api/v3",
+ RawBaseURL: webBaseURL + "/raw",
+ }, nil
+}
+
+func parseGitHubRefPathParts(repoURL *url.URL, githubBaseURL string) []string {
+ parts := strings.Split(strings.Trim(repoURL.Path, "/"), "/")
+ if len(parts) == 0 {
+ return parts
+ }
+ if githubBaseURL == "" {
+ return parts
+ }
+ baseURL, err := url.Parse(strings.TrimSpace(githubBaseURL))
+ if err != nil {
+ return parts
+ }
+ if !strings.EqualFold(repoURL.Host, baseURL.Host) || !strings.EqualFold(repoURL.Scheme, baseURL.Scheme) {
+ return parts
+ }
+ baseParts := strings.Split(strings.Trim(baseURL.Path, "/"), "/")
+ if len(baseParts) == 1 && baseParts[0] == "" {
+ baseParts = nil
+ }
+ if len(baseParts) == 0 || len(parts) < len(baseParts)+2 {
+ return parts
+ }
+ for i, part := range baseParts {
+ if parts[i] != part {
+ return parts
+ }
+ }
+ return parts[len(baseParts):]
+}
+
+func supportedGitHubBaseURL(repoURL *url.URL, githubBaseURL string) string {
+ if repoURL == nil {
+ return ""
+ }
+ trimmedBaseURL := strings.TrimSpace(githubBaseURL)
+ if trimmedBaseURL != "" && matchesGitHubWebBase(repoURL, trimmedBaseURL) {
+ return trimmedBaseURL
+ }
+ if matchesGitHubWebBase(repoURL, "https://github.com") {
+ return "https://github.com"
+ }
+ return ""
+}
+
+func matchesGitHubWebBase(repoURL *url.URL, webBaseURL string) bool {
+ baseURL, err := url.Parse(strings.TrimSpace(webBaseURL))
+ if err != nil {
+ return false
+ }
+ if !strings.EqualFold(repoURL.Scheme, baseURL.Scheme) {
+ return false
+ }
+ if !strings.EqualFold(repoURL.Host, baseURL.Host) {
+ return false
+ }
+ basePath := strings.Trim(baseURL.Path, "/")
+ if basePath == "" {
+ return true
+ }
+ repoPath := strings.Trim(repoURL.Path, "/")
+ return repoPath == basePath || strings.HasPrefix(repoPath, basePath+"/")
+}
+
+func splitGitHubTreeOrBlobRefPath(parts []string, defaultRef string) (string, string) {
+ if len(parts) == 0 {
+ return defaultRef, ""
+ }
+ if anchor := knownSkillSubPathAnchor(parts); anchor > 0 {
+ return strings.Join(parts[:anchor], "/"), strings.Join(parts[anchor:], "/")
+ }
+ if parts[len(parts)-1] == "SKILL.md" {
+ return strings.Join(parts[:len(parts)-1], "/"), "SKILL.md"
+ }
+ return parts[0], strings.Join(parts[1:], "/")
+}
+
+func knownSkillSubPathAnchor(parts []string) int {
+ for i := 1; i < len(parts); i++ {
+ candidateSubPath := strings.Join(parts[i:], "/")
+ if strings.HasPrefix(candidateSubPath, ".agents/skills/") || strings.HasPrefix(candidateSubPath, "skills/") {
+ return i
+ }
+ }
+ return -1
+}
+
+func isSkillMarkdownPath(subPath string) bool {
+ subPath = strings.Trim(strings.TrimSpace(subPath), "/")
+ return subPath == "SKILL.md" || strings.HasSuffix(subPath, "/SKILL.md")
+}
+
// parseGitHubRef parses a GitHub reference.
// Supports: "owner/repo", "owner/repo/path", or full URL like "https://github.com/owner/repo/tree/ref/path"
func parseGitHubRef(repo string) (GitHubRef, error) {
+ return parseGitHubRefWithBaseURL(repo, "", "main")
+}
+
+func parseGitHubRefWithBaseURL(repo, githubBaseURL, defaultRef string) (GitHubRef, error) {
+ target, err := parseGitHubTargetWithBaseURL(repo, githubBaseURL, defaultRef)
+ if err != nil {
+ return GitHubRef{}, err
+ }
+ return target.Ref, nil
+}
+
+func parseGitHubTargetWithBaseURL(repo, githubBaseURL, defaultRef string) (gitHubTarget, error) {
repo = strings.TrimSpace(repo)
+ defaultRef = strings.TrimSpace(defaultRef)
// Handle full URL
if strings.HasPrefix(repo, "http://") || strings.HasPrefix(repo, "https://") {
u, err := url.Parse(repo)
if err != nil {
- return GitHubRef{}, fmt.Errorf("invalid URL: %w", err)
+ return gitHubTarget{}, fmt.Errorf("invalid URL: %w", err)
}
- parts := strings.Split(strings.Trim(u.Path, "/"), "/")
+ matchedBaseURL := supportedGitHubBaseURL(u, githubBaseURL)
+ if matchedBaseURL == "" {
+ return gitHubTarget{}, fmt.Errorf("invalid GitHub URL host %q", u.Host)
+ }
+ endpoints, err := resolveGitHubEndpoints(matchedBaseURL)
+ if err != nil {
+ return gitHubTarget{}, err
+ }
+ parts := parseGitHubRefPathParts(u, matchedBaseURL)
if len(parts) < 2 {
- return GitHubRef{}, fmt.Errorf("invalid GitHub URL")
+ return gitHubTarget{}, fmt.Errorf("invalid GitHub URL")
+ }
+ if len(parts) > 2 {
+ if parts[2] != "tree" && parts[2] != "blob" {
+ return gitHubTarget{}, fmt.Errorf("invalid GitHub repository URL path %q", u.Path)
+ }
+ if len(parts) < 4 {
+ return gitHubTarget{}, fmt.Errorf("invalid GitHub %s URL path %q", parts[2], u.Path)
+ }
}
ref := GitHubRef{
Owner: parts[0],
RepoName: parts[1],
- Ref: "main",
+ Ref: defaultRef,
}
// Look for /tree/ or /blob/ in the path
for i := 2; i < len(parts); i++ {
if parts[i] == "tree" || parts[i] == "blob" {
if i+1 < len(parts) {
- ref.Ref = parts[i+1]
- ref.SubPath = strings.Join(parts[i+2:], "/")
+ ref.Ref, ref.SubPath = splitGitHubTreeOrBlobRefPath(parts[i+1:], defaultRef)
}
break
}
}
- return ref, nil
+ return gitHubTarget{Ref: ref, Endpoints: endpoints}, nil
+ }
+
+ endpoints, err := resolveGitHubEndpoints(githubBaseURL)
+ if err != nil {
+ return gitHubTarget{}, err
}
// Handle shorthand format
parts := strings.Split(strings.Trim(repo, "/"), "/")
if len(parts) < 2 {
- return GitHubRef{}, fmt.Errorf("invalid format %q: expected 'owner/repo'", repo)
+ return gitHubTarget{}, fmt.Errorf("invalid format %q: expected 'owner/repo'", repo)
}
ref := GitHubRef{
Owner: parts[0],
RepoName: parts[1],
- Ref: "main",
+ Ref: defaultRef,
}
if len(parts) > 2 {
ref.SubPath = strings.Join(parts[2:], "/")
}
- return ref, nil
+ return gitHubTarget{Ref: ref, Endpoints: endpoints}, nil
+}
+
+type gitHubRepository struct {
+ DefaultBranch string `json:"default_branch"`
+}
+
+func (si *SkillInstaller) resolveGitHubTarget(ctx context.Context, repo, version string) (gitHubTarget, error) {
+ target, err := parseGitHubTargetWithBaseURL(repo, si.githubBaseURL, "")
+ if err != nil {
+ return gitHubTarget{}, err
+ }
+ if version != "" {
+ target.Ref.Ref = version
+ return target, nil
+ }
+ if target.Ref.Ref != "" {
+ return target, nil
+ }
+ defaultBranch, err := si.fetchDefaultBranchWithAPIBaseURL(
+ ctx,
+ target.Endpoints.APIBaseURL,
+ target.Ref.Owner,
+ target.Ref.RepoName,
+ )
+ if err != nil {
+ return gitHubTarget{}, err
+ }
+ target.Ref.Ref = defaultBranch
+ return target, nil
+}
+
+func (si *SkillInstaller) fetchDefaultBranchWithAPIBaseURL(
+ ctx context.Context,
+ apiBaseURL, owner, repo string,
+) (string, error) {
+ apiURL := fmt.Sprintf("%s/repos/%s/%s", strings.TrimRight(apiBaseURL, "/"), owner, repo)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
+ if err != nil {
+ return "", err
+ }
+ if si.githubToken != "" {
+ req.Header.Set("Authorization", "Bearer "+si.githubToken)
+ }
+
+ resp, err := utils.DoRequestWithRetry(si.client, req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return "", fmt.Errorf("failed to read repository metadata: %w", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("failed to resolve default branch: HTTP %d: %s", resp.StatusCode, string(body))
+ }
+
+ var repository gitHubRepository
+ if err := json.Unmarshal(body, &repository); err != nil {
+ return "", fmt.Errorf("failed to parse repository metadata: %w", err)
+ }
+ if strings.TrimSpace(repository.DefaultBranch) == "" {
+ return "", fmt.Errorf("repository %s/%s did not report a default branch", owner, repo)
+ }
+ return repository.DefaultBranch, nil
+}
+
+func githubInstallDirNameWithBaseURL(repo, githubBaseURL string) (string, error) {
+ if !strings.HasPrefix(repo, "http://") && !strings.HasPrefix(repo, "https://") {
+ if err := ValidateInstallTarget(repo); err != nil {
+ return "", err
+ }
+ }
+ ref, err := parseGitHubRefWithBaseURL(repo, githubBaseURL, "main")
+ if err != nil {
+ return "", err
+ }
+ if ref.SubPath != "" {
+ if isSkillMarkdownPath(ref.SubPath) {
+ skillDir := path.Dir(strings.Trim(ref.SubPath, "/"))
+ if skillDir == "." || skillDir == "" {
+ return ref.RepoName, nil
+ }
+ return path.Base(skillDir), nil
+ }
+ return filepath.Base(ref.SubPath), nil
+ }
+ return ref.RepoName, nil
}
func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) error {
- ref, err := parseGitHubRef(repo)
+ skillName, err := githubInstallDirNameWithBaseURL(repo, si.githubBaseURL)
if err != nil {
return err
}
-
- skillName := ref.RepoName
- if ref.SubPath != "" {
- skillName = filepath.Base(ref.SubPath)
- }
skillDirectory := filepath.Join(si.workspace, "skills", skillName)
- if _, err := os.Stat(skillDirectory); err == nil {
+ if _, statErr := os.Stat(skillDirectory); statErr == nil {
return fmt.Errorf("skill '%s' already exists", skillName)
}
+ _, err = si.InstallFromGitHubToDir(ctx, repo, "", skillDirectory)
+ return err
+}
+
+func (si *SkillInstaller) InstallFromGitHubToDir(
+ ctx context.Context,
+ repo, version, skillDirectory string,
+) (*InstallResult, error) {
+ target, err := si.resolveGitHubTarget(ctx, repo, version)
+ if err != nil {
+ return nil, err
+ }
+ ref := target.Ref
+ apiSubPath := strings.Trim(ref.SubPath, "/")
+ if isSkillMarkdownPath(apiSubPath) {
+ if dir := path.Dir(apiSubPath); dir == "." {
+ apiSubPath = ""
+ } else {
+ apiSubPath = dir
+ }
+ }
// Build GitHub API URL
apiPath := path.Join(ref.Owner, ref.RepoName, "contents")
- if ref.SubPath != "" {
- apiPath = path.Join(apiPath, ref.SubPath)
+ if apiSubPath != "" {
+ apiPath = path.Join(apiPath, apiSubPath)
}
- apiURL := fmt.Sprintf("https://api.github.com/repos/%s?ref=%s", apiPath, ref.Ref)
+ apiURL := fmt.Sprintf("%s/repos/%s?ref=%s", target.Endpoints.APIBaseURL, apiPath, url.QueryEscape(ref.Ref))
if err := si.getGithubDirAllFiles(ctx, apiURL, skillDirectory, true); err != nil {
// Fallback to raw download
- return si.downloadRaw(ctx, ref.Owner, ref.RepoName, ref.Ref, ref.SubPath, skillDirectory)
+ if downloadErr := si.downloadRaw(
+ ctx,
+ target.Endpoints.RawBaseURL,
+ ref.Owner,
+ ref.RepoName,
+ ref.Ref,
+ ref.SubPath,
+ skillDirectory,
+ ); downloadErr != nil {
+ return nil, downloadErr
+ }
+ } else if _, err := os.Stat(filepath.Join(skillDirectory, "SKILL.md")); err != nil {
+ return nil, fmt.Errorf("SKILL.md not found in repository")
}
- if _, err := os.Stat(filepath.Join(skillDirectory, "SKILL.md")); err != nil {
- return fmt.Errorf("SKILL.md not found in repository")
- }
- return nil
+ return &InstallResult{Version: ref.Ref}, nil
}
// downloadDir recursively downloads a directory from GitHub API
@@ -188,12 +514,19 @@ func (si *SkillInstaller) getGithubDirAllFiles(ctx context.Context, apiURL, loca
}
// downloadRaw is a fallback that downloads just SKILL.md from raw.githubusercontent.com
-func (si *SkillInstaller) downloadRaw(ctx context.Context, owner, repo, ref, subPath, localDir string) error {
+func (si *SkillInstaller) downloadRaw(
+ ctx context.Context,
+ rawBaseURL, owner, repo, ref, subPath, localDir string,
+) error {
urlPath := path.Join(owner, repo, ref)
if subPath != "" {
- urlPath = path.Join(urlPath, subPath)
+ if isSkillMarkdownPath(subPath) {
+ urlPath = strings.TrimSuffix(path.Join(urlPath, subPath), "/SKILL.md")
+ } else {
+ urlPath = path.Join(urlPath, subPath)
+ }
}
- url := fmt.Sprintf("https://raw.githubusercontent.com/%s/SKILL.md", urlPath)
+ url := fmt.Sprintf("%s/%s/SKILL.md", strings.TrimRight(rawBaseURL, "/"), urlPath)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
@@ -213,12 +546,10 @@ func (si *SkillInstaller) downloadRaw(ctx context.Context, owner, repo, ref, sub
localPath := filepath.Join(localDir, "SKILL.md")
- // Atomic move from temp to final location.
- if err := os.Rename(tmpPath, localPath); err != nil {
+ if err := fileutil.CopyFile(tmpPath, localPath, 0o600); err != nil {
return fmt.Errorf("failed to write skill file: %w", err)
}
-
- return os.Chmod(localPath, 0o600)
+ return nil
}
func (si *SkillInstaller) downloadFile(ctx context.Context, url, localPath string) error {
@@ -238,12 +569,10 @@ func (si *SkillInstaller) downloadFile(ctx context.Context, url, localPath strin
return err
}
- // Atomic move from temp to final location.
- if err := os.Rename(tmpPath, localPath); err != nil {
+ if err := fileutil.CopyFile(tmpPath, localPath, 0o600); err != nil {
return fmt.Errorf("failed to move downloaded file: %w", err)
}
-
- return os.Chmod(localPath, 0o600)
+ return nil
}
// shouldDownload determines if a file should be downloaded
diff --git a/pkg/skills/installer_test.go b/pkg/skills/installer_test.go
index 759cfc489..9691a5312 100644
--- a/pkg/skills/installer_test.go
+++ b/pkg/skills/installer_test.go
@@ -89,6 +89,12 @@ func TestParseGitHubRef(t *testing.T) {
wantRef: "main",
wantSubPath: "",
},
+ {
+ name: "invalid non github host",
+ repo: "https://gitlab.com/sipeed/picoclaw/-/tree/main/skills/test",
+ wantErr: true,
+ wantErrContain: `invalid GitHub URL host "gitlab.com"`,
+ },
}
for _, tt := range tests {
@@ -127,6 +133,268 @@ func TestParseGitHubRef(t *testing.T) {
}
}
+func TestParseGitHubRefWithBaseURL(t *testing.T) {
+ ref, err := parseGitHubRefWithBaseURL(
+ "https://ghe.example.com/git/org/repo/tree/dev/skills/test",
+ "https://ghe.example.com/git",
+ "main",
+ )
+ if err != nil {
+ t.Fatalf("parseGitHubRefWithBaseURL() unexpected error = %v", err)
+ }
+ if ref.Owner != "org" {
+ t.Fatalf("owner = %q, want org", ref.Owner)
+ }
+ if ref.RepoName != "repo" {
+ t.Fatalf("repo = %q, want repo", ref.RepoName)
+ }
+ if ref.Ref != "dev" {
+ t.Fatalf("ref = %q, want dev", ref.Ref)
+ }
+ if ref.SubPath != "skills/test" {
+ t.Fatalf("subPath = %q, want skills/test", ref.SubPath)
+ }
+
+ dirName, err := githubInstallDirNameWithBaseURL(
+ "https://ghe.example.com/git/org/repo/tree/dev/skills/test",
+ "https://ghe.example.com/git",
+ )
+ if err != nil {
+ t.Fatalf("githubInstallDirNameWithBaseURL() unexpected error = %v", err)
+ }
+ if dirName != "test" {
+ t.Fatalf("dirName = %q, want test", dirName)
+ }
+
+ dirName, err = githubInstallDirNameWithBaseURL(
+ "https://ghe.example.com/git/org/repo/blob/dev/skills/test/SKILL.md",
+ "https://ghe.example.com/git",
+ )
+ if err != nil {
+ t.Fatalf("githubInstallDirNameWithBaseURL() unexpected error for blob skill url = %v", err)
+ }
+ if dirName != "test" {
+ t.Fatalf("dirName for nested blob skill = %q, want test", dirName)
+ }
+
+ dirName, err = githubInstallDirNameWithBaseURL(
+ "https://ghe.example.com/git/org/repo/blob/dev/SKILL.md",
+ "https://ghe.example.com/git",
+ )
+ if err != nil {
+ t.Fatalf("githubInstallDirNameWithBaseURL() unexpected error for repo root blob skill = %v", err)
+ }
+ if dirName != "repo" {
+ t.Fatalf("dirName for repo root blob skill = %q, want repo", dirName)
+ }
+
+ ref, err = parseGitHubRefWithBaseURL("https://ghe.example.com/git/org/repo", "https://ghe.example.com/git", "")
+ if err != nil {
+ t.Fatalf("parseGitHubRefWithBaseURL() unexpected error = %v", err)
+ }
+ if ref.Ref != "" {
+ t.Fatalf("ref = %q, want empty", ref.Ref)
+ }
+
+ ref, err = parseGitHubRefWithBaseURL(
+ "https://github.com/org/repo/tree/feature/skills-registry/.agents/skills/pr-review",
+ "",
+ "main",
+ )
+ if err != nil {
+ t.Fatalf("parseGitHubRefWithBaseURL() unexpected error for slash branch = %v", err)
+ }
+ if ref.Ref != "feature/skills-registry" {
+ t.Fatalf("ref = %q, want feature/skills-registry", ref.Ref)
+ }
+ if ref.SubPath != ".agents/skills/pr-review" {
+ t.Fatalf("subPath = %q, want .agents/skills/pr-review", ref.SubPath)
+ }
+
+ _, err = parseGitHubRefWithBaseURL(
+ "https://gitlab.example.com/org/repo/-/tree/dev/skills/test",
+ "https://ghe.example.com/git",
+ "main",
+ )
+ if err == nil {
+ t.Fatal("parseGitHubRefWithBaseURL() error = nil, want invalid host error")
+ }
+ if !strings.Contains(err.Error(), `invalid GitHub URL host "gitlab.example.com"`) {
+ t.Fatalf("unexpected error = %v", err)
+ }
+
+ _, err = parseGitHubRefWithBaseURL(
+ "http://ghe.example.com/git/org/repo/tree/dev/skills/test",
+ "https://ghe.example.com/git",
+ "main",
+ )
+ if err == nil {
+ t.Fatal("parseGitHubRefWithBaseURL() error = nil, want invalid host error for scheme mismatch")
+ }
+ if !strings.Contains(err.Error(), `invalid GitHub URL host "ghe.example.com"`) {
+ t.Fatalf("unexpected scheme mismatch error = %v", err)
+ }
+
+ _, err = parseGitHubRefWithBaseURL(
+ "https://github.com/org/repo/pull/2442",
+ "",
+ "main",
+ )
+ if err == nil {
+ t.Fatal("parseGitHubRefWithBaseURL() error = nil, want invalid repository URL path error")
+ }
+ if !strings.Contains(err.Error(), `invalid GitHub repository URL path "/org/repo/pull/2442"`) {
+ t.Fatalf("unexpected PR URL error = %v", err)
+ }
+
+ _, err = parseGitHubRefWithBaseURL(
+ "https://github.com/org/repo/tree",
+ "",
+ "main",
+ )
+ if err == nil {
+ t.Fatal("parseGitHubRefWithBaseURL() error = nil, want invalid tree URL path error")
+ }
+ if !strings.Contains(err.Error(), `invalid GitHub tree URL path "/org/repo/tree"`) {
+ t.Fatalf("unexpected short tree URL error = %v", err)
+ }
+}
+
+func TestParseGitHubTargetWithBaseURLPreservesSourceEndpoints(t *testing.T) {
+ target, err := parseGitHubTargetWithBaseURL(
+ "https://github.com/org/repo/tree/main/.agents/skills/pr-review",
+ "https://ghe.example.com/git",
+ "",
+ )
+ if err != nil {
+ t.Fatalf("parseGitHubTargetWithBaseURL() unexpected error = %v", err)
+ }
+ if target.Endpoints.WebBaseURL != "https://github.com" {
+ t.Fatalf("web base = %q, want https://github.com", target.Endpoints.WebBaseURL)
+ }
+ if target.Endpoints.APIBaseURL != "https://api.github.com" {
+ t.Fatalf("api base = %q, want https://api.github.com", target.Endpoints.APIBaseURL)
+ }
+ if target.Endpoints.RawBaseURL != "https://raw.githubusercontent.com" {
+ t.Fatalf("raw base = %q, want https://raw.githubusercontent.com", target.Endpoints.RawBaseURL)
+ }
+ if target.Ref.Owner != "org" || target.Ref.RepoName != "repo" {
+ t.Fatalf("unexpected ref = %+v", target.Ref)
+ }
+ if target.Ref.Ref != "main" {
+ t.Fatalf("ref = %q, want main", target.Ref.Ref)
+ }
+ if target.Ref.SubPath != ".agents/skills/pr-review" {
+ t.Fatalf("subPath = %q, want .agents/skills/pr-review", target.Ref.SubPath)
+ }
+}
+
+func TestParseGitHubTargetWithBaseURLPreservesSlashBranchForRepoRootBlobSkill(t *testing.T) {
+ target, err := parseGitHubTargetWithBaseURL(
+ "https://github.com/org/repo/blob/feature/skills-registry/SKILL.md",
+ "",
+ "",
+ )
+ if err != nil {
+ t.Fatalf("parseGitHubTargetWithBaseURL() unexpected error = %v", err)
+ }
+ if target.Ref.Ref != "feature/skills-registry" {
+ t.Fatalf("ref = %q, want feature/skills-registry", target.Ref.Ref)
+ }
+ if target.Ref.SubPath != "SKILL.md" {
+ t.Fatalf("subPath = %q, want SKILL.md", target.Ref.SubPath)
+ }
+}
+
+func TestSkillInstallerResolveGitHubRefUsesDefaultBranch(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v3/repos/org/repo":
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"default_branch":"master"}`))
+ default:
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ }))
+ defer server.Close()
+
+ installer, err := NewSkillInstallerWithBaseURL(t.TempDir(), server.URL, "", "")
+ if err != nil {
+ t.Fatalf("NewSkillInstallerWithBaseURL() error = %v", err)
+ }
+
+ target, err := installer.resolveGitHubTarget(context.Background(), "org/repo/skills/test", "")
+ if err != nil {
+ t.Fatalf("resolveGitHubTarget() error = %v", err)
+ }
+ ref := target.Ref
+ if ref.Ref != "master" {
+ t.Fatalf("ref = %q, want master", ref.Ref)
+ }
+ if ref.SubPath != "skills/test" {
+ t.Fatalf("subPath = %q, want skills/test", ref.SubPath)
+ }
+}
+
+func TestSkillInstallerInstallFromGitHubToDirSupportsBlobSkillURL(t *testing.T) {
+ tmpDir := t.TempDir()
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v3/repos/org/repo/contents/.agents/skills/pr-review":
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`[
+ {"type":"file","name":"SKILL.md","download_url":"` + server.URL + `/raw/org/repo/main/.agents/skills/pr-review/SKILL.md"},
+ {"type":"dir","name":"scripts","url":"` + server.URL + `/api/v3/repos/org/repo/contents/.agents/skills/pr-review/scripts?ref=main"}
+ ]`))
+ case "/api/v3/repos/org/repo/contents/.agents/skills/pr-review/scripts":
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`[
+ {"type":"file","name":"check.sh","download_url":"` + server.URL + `/raw/org/repo/main/.agents/skills/pr-review/scripts/check.sh"}
+ ]`))
+ case "/raw/org/repo/main/.agents/skills/pr-review/SKILL.md":
+ _, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n"))
+ case "/raw/org/repo/main/.agents/skills/pr-review/scripts/check.sh":
+ _, _ = w.Write([]byte("#!/bin/sh\nexit 0\n"))
+ default:
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ }))
+ defer server.Close()
+
+ installer, err := NewSkillInstallerWithBaseURL(tmpDir, server.URL, "", "")
+ if err != nil {
+ t.Fatalf("NewSkillInstallerWithBaseURL() error = %v", err)
+ }
+
+ targetDir := filepath.Join(tmpDir, "skills", "pr-review")
+ result, err := installer.InstallFromGitHubToDir(
+ context.Background(),
+ server.URL+"/org/repo/blob/main/.agents/skills/pr-review/SKILL.md",
+ "",
+ targetDir,
+ )
+ if err != nil {
+ t.Fatalf("InstallFromGitHubToDir() error = %v", err)
+ }
+ if result.Version != "main" {
+ t.Fatalf("version = %q, want main", result.Version)
+ }
+
+ content, err := os.ReadFile(filepath.Join(targetDir, "SKILL.md"))
+ if err != nil {
+ t.Fatalf("ReadFile(SKILL.md) error = %v", err)
+ }
+ if !strings.Contains(string(content), "name: pr-review") {
+ t.Fatalf("SKILL.md content = %q, want skill metadata", string(content))
+ }
+
+ scriptPath := filepath.Join(targetDir, "scripts", "check.sh")
+ if _, err := os.Stat(scriptPath); err != nil {
+ t.Fatalf("Stat(scripts/check.sh) error = %v", err)
+ }
+}
+
func TestShouldDownload(t *testing.T) {
tests := []struct {
name string
@@ -197,6 +465,16 @@ func TestNewSkillInstaller(t *testing.T) {
t.Errorf("githubToken = %v, want 'test-token'", installer.githubToken)
}
+ if installer.githubBaseURL != "https://github.com" {
+ t.Errorf("githubBaseURL = %v, want https://github.com", installer.githubBaseURL)
+ }
+ if installer.githubAPIBaseURL != "https://api.github.com" {
+ t.Errorf("githubAPIBaseURL = %v, want https://api.github.com", installer.githubAPIBaseURL)
+ }
+ if installer.githubRawBaseURL != "https://raw.githubusercontent.com" {
+ t.Errorf("githubRawBaseURL = %v, want https://raw.githubusercontent.com", installer.githubRawBaseURL)
+ }
+
if installer.proxy != "" {
t.Errorf("proxy = %v, want empty", installer.proxy)
}
@@ -234,6 +512,24 @@ func TestNewSkillInstaller_WithProxy(t *testing.T) {
}
}
+func TestNewSkillInstaller_WithBaseURL(t *testing.T) {
+ tmpDir := t.TempDir()
+ installer, err := NewSkillInstallerWithBaseURL(tmpDir, "https://github.example.com", "test-token", "")
+ if err != nil {
+ t.Fatalf("NewSkillInstallerWithBaseURL() error = %v", err)
+ }
+
+ if installer.githubBaseURL != "https://github.example.com" {
+ t.Errorf("githubBaseURL = %v, want https://github.example.com", installer.githubBaseURL)
+ }
+ if installer.githubAPIBaseURL != "https://github.example.com/api/v3" {
+ t.Errorf("githubAPIBaseURL = %v, want https://github.example.com/api/v3", installer.githubAPIBaseURL)
+ }
+ if installer.githubRawBaseURL != "https://github.example.com/raw" {
+ t.Errorf("githubRawBaseURL = %v, want https://github.example.com/raw", installer.githubRawBaseURL)
+ }
+}
+
func TestNewSkillInstaller_InvalidProxy(t *testing.T) {
tmpDir := t.TempDir()
installer, err := NewSkillInstaller(tmpDir, "test-token", "://invalid-proxy")
diff --git a/pkg/skills/provider_factory.go b/pkg/skills/provider_factory.go
new file mode 100644
index 000000000..fe2849e1e
--- /dev/null
+++ b/pkg/skills/provider_factory.go
@@ -0,0 +1,33 @@
+package skills
+
+import (
+ "sync"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+type RegistryProviderBuilder func(name string, cfg config.SkillRegistryConfig) RegistryProvider
+
+var (
+ registryProviderBuildersMu sync.RWMutex
+ registryProviderBuilders = map[string]RegistryProviderBuilder{}
+)
+
+func RegisterRegistryProviderBuilder(name string, builder RegistryProviderBuilder) {
+ if name == "" || builder == nil {
+ return
+ }
+ registryProviderBuildersMu.Lock()
+ defer registryProviderBuildersMu.Unlock()
+ registryProviderBuilders[name] = builder
+}
+
+func buildRegistryProvider(name string, cfg config.SkillRegistryConfig) RegistryProvider {
+ registryProviderBuildersMu.RLock()
+ defer registryProviderBuildersMu.RUnlock()
+ builder := registryProviderBuilders[name]
+ if builder == nil {
+ return nil
+ }
+ return builder(name, cfg)
+}
diff --git a/pkg/skills/registry.go b/pkg/skills/registry.go
index 45ae72253..6c8e28a4e 100644
--- a/pkg/skills/registry.go
+++ b/pkg/skills/registry.go
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"log/slog"
+ "path"
+ "strings"
"sync"
"time"
)
@@ -42,11 +44,25 @@ type InstallResult struct {
Summary string
}
+// RegistryProvider creates a registry instance from configuration.
+// Different hubs can implement this to plug into the shared manager.
+type RegistryProvider interface {
+ IsEnabled() bool
+ BuildRegistry() SkillRegistry
+}
+
// SkillRegistry is the interface that all skill registries must implement.
// Each registry represents a different source of skills (e.g., clawhub.ai)
type SkillRegistry interface {
// Name returns the unique name of this registry (e.g., "clawhub").
Name() string
+ // ResolveInstallDirName returns the directory name to use under workspace/skills
+ // for a given install target. Different registries can interpret the target
+ // differently (for example, a slug vs owner/repo/path).
+ ResolveInstallDirName(target string) (string, error)
+ // SkillURL returns the web URL for a skill slug if the registry exposes one.
+ // version is optional and can be used by registries whose URLs depend on a ref.
+ SkillURL(slug, version string) string
// Search searches the registry for skills matching the query.
Search(ctx context.Context, query string, limit int) ([]SearchResult, error)
// GetSkillMeta retrieves metadata for a specific skill by slug.
@@ -57,10 +73,31 @@ type SkillRegistry interface {
DownloadAndInstall(ctx context.Context, slug, version, targetDir string) (*InstallResult, error)
}
+// InstallTargetNormalizer is implemented by registries that can canonicalize
+// user-provided install targets into a stable slug for origin metadata.
+type InstallTargetNormalizer interface {
+ NormalizeInstallTarget(target string) string
+}
+
+func NormalizeInstallTargetForRegistryInstance(registry SkillRegistry, target string) string {
+ if registry == nil || target == "" {
+ return target
+ }
+ normalizer, ok := registry.(InstallTargetNormalizer)
+ if !ok {
+ return target
+ }
+ normalized := normalizer.NormalizeInstallTarget(target)
+ if normalized == "" {
+ return target
+ }
+ return normalized
+}
+
// RegistryConfig holds configuration for all skill registries.
// This is the input to NewRegistryManagerFromConfig.
type RegistryConfig struct {
- ClawHub ClawHubConfig
+ Providers []RegistryProvider
MaxConcurrentSearches int
}
@@ -85,6 +122,29 @@ type RegistryManager struct {
mu sync.RWMutex
}
+func ValidateInstallTarget(target string) error {
+ target = strings.TrimSpace(target)
+ if target == "" {
+ return fmt.Errorf("identifier is required and must be a non-empty string")
+ }
+ if strings.Contains(target, "\\") {
+ return fmt.Errorf("identifier %q contains invalid path separators", target)
+ }
+ clean := path.Clean("/" + target)
+ if clean == "/" || strings.HasPrefix(clean, "/../") || clean == "/.." {
+ return fmt.Errorf("identifier %q contains invalid path traversal", target)
+ }
+ if strings.Contains(target, "//") {
+ return fmt.Errorf("identifier %q contains empty path segments", target)
+ }
+ for _, segment := range strings.Split(strings.Trim(target, "/"), "/") {
+ if segment == "." || segment == ".." || segment == "" {
+ return fmt.Errorf("identifier %q contains invalid path segments", target)
+ }
+ }
+ return nil
+}
+
// NewRegistryManager creates an empty RegistryManager.
func NewRegistryManager() *RegistryManager {
return &RegistryManager{
@@ -100,8 +160,15 @@ func NewRegistryManagerFromConfig(cfg RegistryConfig) *RegistryManager {
if cfg.MaxConcurrentSearches > 0 {
rm.maxConcurrent = cfg.MaxConcurrentSearches
}
- if cfg.ClawHub.Enabled {
- rm.AddRegistry(NewClawHubRegistry(cfg.ClawHub))
+ for _, provider := range cfg.Providers {
+ if provider == nil || !provider.IsEnabled() {
+ continue
+ }
+ registry := provider.BuildRegistry()
+ if registry == nil {
+ continue
+ }
+ rm.AddRegistry(registry)
}
return rm
}
diff --git a/pkg/skills/registry_test.go b/pkg/skills/registry_test.go
index a4694bd43..6ac5ffbf3 100644
--- a/pkg/skills/registry_test.go
+++ b/pkg/skills/registry_test.go
@@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/assert"
+ "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/utils"
)
@@ -24,6 +25,10 @@ type mockRegistry struct {
func (m *mockRegistry) Name() string { return m.name }
+func (m *mockRegistry) ResolveInstallDirName(target string) (string, error) { return target, nil }
+
+func (m *mockRegistry) SkillURL(slug, _ string) string { return "https://example.com/skills/" + slug }
+
func (m *mockRegistry) Search(_ context.Context, _ string, _ int) ([]SearchResult, error) {
return m.searchResults, m.searchErr
}
@@ -170,6 +175,31 @@ func TestSortByScoreDesc(t *testing.T) {
assert.Equal(t, "c", results[2].Slug)
}
+type mockProvider struct {
+ enabled bool
+ registry SkillRegistry
+}
+
+func (m mockProvider) IsEnabled() bool {
+ return m.enabled
+}
+
+func (m mockProvider) BuildRegistry() SkillRegistry {
+ return m.registry
+}
+
+func TestNewRegistryManagerFromConfigProviders(t *testing.T) {
+ mgr := NewRegistryManagerFromConfig(RegistryConfig{
+ Providers: []RegistryProvider{
+ mockProvider{enabled: true, registry: &mockRegistry{name: "alpha"}},
+ mockProvider{enabled: false, registry: &mockRegistry{name: "beta"}},
+ },
+ })
+
+ assert.NotNil(t, mgr.GetRegistry("alpha"))
+ assert.Nil(t, mgr.GetRegistry("beta"))
+}
+
func TestIsSafeSlug(t *testing.T) {
assert.NoError(t, utils.ValidateSkillIdentifier("github"))
assert.NoError(t, utils.ValidateSkillIdentifier("docker-compose"))
@@ -178,3 +208,50 @@ func TestIsSafeSlug(t *testing.T) {
assert.Error(t, utils.ValidateSkillIdentifier("path/traversal"))
assert.Error(t, utils.ValidateSkillIdentifier("path\\traversal"))
}
+
+func TestLegacyGithubBaseURLOverridesDefaultRegistryBaseURL(t *testing.T) {
+ cfg := config.DefaultConfig().Tools.Skills
+ cfg.Github.BaseURL = "https://ghe.example.com/git"
+
+ registry := LookupRegistryFromToolsConfig(cfg, "github")
+ assert.NotNil(t, registry)
+
+ ghRegistry, ok := registry.(*GitHubRegistry)
+ assert.True(t, ok)
+ assert.Equal(t, "https://ghe.example.com/git", ghRegistry.webBase)
+}
+
+func TestExplicitGithubRegistryBaseURLBeatsLegacyCompat(t *testing.T) {
+ cfg := config.DefaultConfig().Tools.Skills
+ cfg.Github.BaseURL = "https://ghe-legacy.example.com/git"
+ cfg.Registries.Set("github", config.SkillRegistryConfig{
+ Name: "github",
+ Enabled: true,
+ BaseURL: "https://ghe-explicit.example.com/scm",
+ Param: map[string]any{},
+ })
+
+ registry := LookupRegistryFromToolsConfig(cfg, "github")
+ assert.NotNil(t, registry)
+
+ ghRegistry, ok := registry.(*GitHubRegistry)
+ assert.True(t, ok)
+ assert.Equal(t, "https://ghe-explicit.example.com/scm", ghRegistry.webBase)
+}
+
+func TestNormalizeInstallTargetForRegistryCanonicalizesGitHubURLs(t *testing.T) {
+ cfg := config.DefaultConfig().Tools.Skills
+ cfg.Registries.Set("github", config.SkillRegistryConfig{
+ Name: "github",
+ Enabled: true,
+ BaseURL: "https://ghe.example.com/git",
+ Param: map[string]any{},
+ })
+
+ got := NormalizeInstallTargetForRegistry(
+ cfg,
+ "github",
+ "https://ghe.example.com/git/org/repo/tree/dev/skills/pr-review",
+ )
+ assert.Equal(t, "org/repo/skills/pr-review", got)
+}
diff --git a/pkg/tokenizer/estimator.go b/pkg/tokenizer/estimator.go
new file mode 100644
index 000000000..3265edaa8
--- /dev/null
+++ b/pkg/tokenizer/estimator.go
@@ -0,0 +1,91 @@
+package tokenizer
+
+import (
+ "encoding/json"
+ "unicode/utf8"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// EstimateMessageTokens estimates the token count for a single message,
+// including Content, ReasoningContent, ToolCalls arguments, ToolCallID
+// metadata, and Media items. Uses a heuristic of 2.5 characters per token.
+func EstimateMessageTokens(msg providers.Message) int {
+ contentChars := utf8.RuneCountInString(msg.Content)
+
+ // SystemParts are structured system blocks used for cache-aware adapters.
+ // They carry the same content as Content, but in multiple blocks.
+ // We estimate them as an alternative representation, not additive.
+ systemPartsChars := 0
+ if len(msg.SystemParts) > 0 {
+ for _, part := range msg.SystemParts {
+ systemPartsChars += utf8.RuneCountInString(part.Text)
+ }
+ // Per-part overhead for JSON structure (type, text, cache_control).
+ const perPartOverhead = 20
+ systemPartsChars += len(msg.SystemParts) * perPartOverhead
+ }
+
+ // Use the larger of the two representations to stay conservative.
+ chars := contentChars
+ if systemPartsChars > chars {
+ chars = systemPartsChars
+ }
+
+ chars += utf8.RuneCountInString(msg.ReasoningContent)
+
+ for _, tc := range msg.ToolCalls {
+ chars += len(tc.ID) + len(tc.Type)
+ if tc.Function != nil {
+ // Count function name + arguments (the wire format for most providers).
+ // tc.Name mirrors tc.Function.Name — count only once to avoid double-counting.
+ chars += len(tc.Function.Name) + len(tc.Function.Arguments)
+ } else {
+ // Fallback: some provider formats use top-level Name without Function.
+ chars += len(tc.Name)
+ }
+ }
+
+ if msg.ToolCallID != "" {
+ chars += len(msg.ToolCallID)
+ }
+
+ // Per-message overhead for role label, JSON structure, separators.
+ const messageOverhead = 12
+ chars += messageOverhead
+
+ tokens := chars * 2 / 5
+
+ // Media items (images, files) are serialized by provider adapters into
+ // multipart or image_url payloads. Add a fixed per-item token estimate
+ // directly (not through the chars heuristic) since actual cost depends
+ // on resolution and provider-specific image tokenization.
+ const mediaTokensPerItem = 256
+ tokens += len(msg.Media) * mediaTokensPerItem
+
+ return tokens
+}
+
+// EstimateToolDefsTokens estimates the total token cost of tool definitions
+// as they appear in the LLM request.
+func EstimateToolDefsTokens(defs []providers.ToolDefinition) int {
+ if len(defs) == 0 {
+ return 0
+ }
+
+ totalChars := 0
+ for _, d := range defs {
+ totalChars += len(d.Function.Name) + len(d.Function.Description)
+
+ if d.Function.Parameters != nil {
+ if paramJSON, err := json.Marshal(d.Function.Parameters); err == nil {
+ totalChars += len(paramJSON)
+ }
+ }
+
+ // Per-tool overhead: type field, JSON structure, separators.
+ totalChars += 20
+ }
+
+ return totalChars * 2 / 5
+}
diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go
index 60d9d5e5a..a9547eba9 100644
--- a/pkg/tools/cron.go
+++ b/pkg/tools/cron.go
@@ -6,6 +6,8 @@ import (
"strings"
"time"
+ "github.com/google/uuid"
+
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
@@ -18,7 +20,7 @@ type JobExecutor interface {
ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error)
// PublishResponseIfNeeded sends response to the outbound bus only when the
// agent did not already deliver content through the message tool in this round.
- PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string)
+ PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string)
}
// CronTool provides scheduling capabilities for the agent
@@ -92,7 +94,7 @@ func (t *CronTool) Parameters() map[string]any {
},
"command": map[string]any{
"type": "string",
- "description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands.",
+ "description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message.",
},
"command_confirm": map[string]any{
"type": "boolean",
@@ -114,15 +116,6 @@ func (t *CronTool) Parameters() map[string]any {
"type": "string",
"description": "Job ID (for remove/enable/disable)",
},
- "type": map[string]any{
- "type": "string",
- "enum": []string{"message", "directive"},
- "description": "Message generation strategy. 'message' (default): content is sent directly as-is. 'directive': content is treated as instructions for an AI agent to execute before delivery.",
- },
- "deliver": map[string]any{
- "type": "boolean",
- "description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: false",
- },
},
"required": []string{"action"},
}
@@ -199,18 +192,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
return ErrorResult("one of at_seconds, every_seconds, or cron_expr is required")
}
- // Read deliver parameter, default to false so scheduled tasks execute through the agent
- deliver := false
- if d, ok := args["deliver"].(bool); ok {
- deliver = d
- }
-
- // Validate type parameter (server-side whitelist, not just LLM schema hint)
- msgType, _ := args["type"].(string)
- if msgType != "" && msgType != "message" && msgType != "directive" {
- return ErrorResult(fmt.Sprintf("invalid type %q, must be 'message' or 'directive'", msgType))
- }
-
// GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel. When
// allow_command is disabled, explicit confirmation is required as an override.
// Non-command reminders remain open to all channels.
@@ -226,7 +207,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
if !t.allowCommand && !commandConfirm {
return ErrorResult("command_confirm=true is required when allow_command is disabled")
}
- deliver = false
}
// Truncate message for job name (max 30 chars)
@@ -236,7 +216,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
messagePreview,
schedule,
message,
- deliver,
channel,
chatID,
)
@@ -250,10 +229,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
job.Payload.Command = command
needsUpdate = true
}
- if msgType != "" {
- job.Payload.Type = msgType
- needsUpdate = true
- }
if needsUpdate {
t.cronService.UpdateJob(job)
}
@@ -338,8 +313,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
- Channel: channel,
- ChatID: chatID,
+ Context: bus.NewOutboundContext(channel, chatID, ""),
Content: output,
})
return "ok"
@@ -362,47 +336,18 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
- Channel: channel,
- ChatID: chatID,
+ Context: bus.NewOutboundContext(channel, chatID, ""),
Content: output,
})
return "ok"
}
- // Determine message generation strategy
- // Type="directive": treat message as instructions for AI agent to execute
- // Type="" or "message" (default): static message content
- isDirective := job.Payload.Type == "directive"
+ sessionKey := fmt.Sprintf("agent:cron-%s-%s", job.ID, uuid.New().String())
- // If deliver=true and not directive, send message directly without agent processing
- if job.Payload.Deliver && !isDirective {
- pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer pubCancel()
- t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
- Channel: channel,
- ChatID: chatID,
- Content: job.Payload.Message,
- })
- return "ok"
- }
-
- // For deliver=false OR directive mode, process through agent
- sessionKey := fmt.Sprintf("cron-%s", job.ID)
-
- // Prepare the prompt based on type
- prompt := job.Payload.Message
- if isDirective {
- // For directive type, prefix to clarify this is an instruction
- prompt = fmt.Sprintf(
- "Please execute the following directive and provide the result:\n\n%s",
- job.Payload.Message,
- )
- }
-
- // Call agent with the prepared prompt
+ // Call agent with the job message
response, err := t.executor.ProcessDirectWithChannel(
ctx,
- prompt,
+ job.Payload.Message,
sessionKey,
channel,
chatID,
@@ -412,7 +357,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
}
if response != "" {
- t.executor.PublishResponseIfNeeded(ctx, channel, chatID, response)
+ t.executor.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, response)
}
return "ok"
}
diff --git a/pkg/tools/cron_test.go b/pkg/tools/cron_test.go
index 186c6a75e..0e527c98a 100644
--- a/pkg/tools/cron_test.go
+++ b/pkg/tools/cron_test.go
@@ -24,6 +24,7 @@ type stubJobExecutor struct {
publishedResp string
publishedChan string
publishedChatID string
+ publishedKey string
}
func (s *stubJobExecutor) ProcessDirectWithChannel(
@@ -39,7 +40,7 @@ func (s *stubJobExecutor) ProcessDirectWithChannel(
func (s *stubJobExecutor) PublishResponseIfNeeded(
_ context.Context,
- channel, chatID, response string,
+ channel, chatID, sessionKey, response string,
) {
if s.alreadySent {
return
@@ -47,6 +48,7 @@ func (s *stubJobExecutor) PublishResponseIfNeeded(
s.publishedResp = response
s.publishedChan = channel
s.publishedChatID = chatID
+ s.publishedKey = sessionKey
}
func newTestCronToolWithExecutorAndConfig(t *testing.T, executor JobExecutor, cfg *config.Config) *CronTool {
@@ -229,28 +231,6 @@ func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) {
}
}
-func TestCronTool_NonCommandJobDefaultsDeliverToFalse(t *testing.T) {
- tool := newTestCronTool(t)
- ctx := WithToolContext(context.Background(), "telegram", "chat-1")
- result := tool.Execute(ctx, map[string]any{
- "action": "add",
- "message": "send me a poem",
- "at_seconds": float64(600),
- })
-
- if result.IsError {
- t.Fatalf("expected non-command reminder to succeed, got: %s", result.ForLLM)
- }
-
- jobs := tool.cronService.ListJobs(false)
- if len(jobs) != 1 {
- t.Fatalf("expected 1 job, got %d", len(jobs))
- }
- if jobs[0].Payload.Deliver {
- t.Fatal("expected deliver=false by default for non-command jobs")
- }
-}
-
func TestCronTool_ExecuteJobPublishesErrorWhenExecDisabled(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Exec.Enabled = false
@@ -293,8 +273,8 @@ func TestCronTool_ExecuteJobPublishesAgentResponse(t *testing.T) {
t.Fatalf("ExecuteJob() = %q, want ok", got)
}
- if executor.lastKey != "cron-job-1" {
- t.Fatalf("sessionKey = %q, want cron-job-1", executor.lastKey)
+ if !strings.HasPrefix(executor.lastKey, "agent:cron-job-1-") {
+ t.Fatalf("sessionKey = %q, want agent:cron-job-1-{uuid}", executor.lastKey)
}
if executor.lastChan != "telegram" || executor.lastChatID != "chat-1" {
t.Fatalf("executor target = %s/%s, want telegram/chat-1", executor.lastChan, executor.lastChatID)
@@ -305,6 +285,9 @@ func TestCronTool_ExecuteJobPublishesAgentResponse(t *testing.T) {
if executor.publishedResp != "generated reply" {
t.Fatalf("published response = %q, want generated reply", executor.publishedResp)
}
+ if executor.publishedKey != executor.lastKey {
+ t.Fatalf("published sessionKey = %q, want %q", executor.publishedKey, executor.lastKey)
+ }
if executor.publishedChan != "telegram" || executor.publishedChatID != "chat-1" {
t.Fatalf("published target = %s/%s, want telegram/chat-1", executor.publishedChan, executor.publishedChatID)
}
@@ -346,93 +329,6 @@ func TestCronTool_ExecuteJobSkipsWhenMessageToolAlreadySent(t *testing.T) {
}
}
-func TestCronTool_ExecuteJobDirectiveAddsPromptPrefix(t *testing.T) {
- executor := &stubJobExecutor{response: "directive result"}
- tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig())
-
- originalMsg := "check the weather and summarize"
- job := &cron.CronJob{ID: "job-dir-1"}
- job.Payload.Channel = "telegram"
- job.Payload.To = "chat-1"
- job.Payload.Message = originalMsg
- job.Payload.Type = "directive"
-
- if got := tool.ExecuteJob(context.Background(), job); got != "ok" {
- t.Fatalf("ExecuteJob() = %q, want ok", got)
- }
-
- wantPrompt := "Please execute the following directive and provide the result:\n\n" + originalMsg
- if executor.lastPrompt != wantPrompt {
- t.Fatalf("prompt = %q, want exact %q", executor.lastPrompt, wantPrompt)
- }
- if executor.publishedResp != "directive result" {
- t.Fatalf("published response = %q, want %q", executor.publishedResp, "directive result")
- }
-}
-
-func TestCronTool_ExecuteJobDirectiveWithDeliverRoutesToAgent(t *testing.T) {
- executor := &stubJobExecutor{response: "agent processed"}
- tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig())
-
- job := &cron.CronJob{ID: "job-dir-deliver"}
- job.Payload.Channel = "telegram"
- job.Payload.To = "chat-1"
- job.Payload.Message = "generate daily report"
- job.Payload.Type = "directive"
- job.Payload.Deliver = true
-
- if got := tool.ExecuteJob(context.Background(), job); got != "ok" {
- t.Fatalf("ExecuteJob() = %q, want ok", got)
- }
-
- if executor.lastPrompt == "" {
- t.Fatal("expected agent to be called for directive+deliver, but ProcessDirectWithChannel was not invoked")
- }
- if executor.publishedResp != "agent processed" {
- t.Fatalf("published response = %q, want %q", executor.publishedResp, "agent processed")
- }
-
- // Verify no direct publish happened on the bus (agent path, not direct path)
- ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
- defer cancel()
- select {
- case msg := <-tool.msgBus.OutboundChan():
- t.Fatalf("unexpected direct bus message: %+v", msg)
- case <-ctx.Done():
- // expected: no direct bus message
- }
-}
-
-func TestCronTool_ExecuteJobDeliverMessageDirectlyToBus(t *testing.T) {
- executor := &stubJobExecutor{response: "should not be called"}
- tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig())
-
- job := &cron.CronJob{ID: "job-deliver"}
- job.Payload.Channel = "telegram"
- job.Payload.To = "chat-1"
- job.Payload.Message = "hello world"
- job.Payload.Deliver = true
-
- if got := tool.ExecuteJob(context.Background(), job); got != "ok" {
- t.Fatalf("ExecuteJob() = %q, want ok", got)
- }
-
- if executor.lastPrompt != "" {
- t.Fatal("expected agent NOT to be invoked for deliver=true message type")
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
- defer cancel()
- select {
- case msg := <-tool.msgBus.OutboundChan():
- if msg.Content != "hello world" {
- t.Fatalf("bus content = %q, want %q", msg.Content, "hello world")
- }
- case <-ctx.Done():
- t.Fatal("timeout waiting for direct bus message")
- }
-}
-
func TestCronTool_ExecuteJobReturnsErrorWithoutPublish(t *testing.T) {
executor := &stubJobExecutor{
response: "this response must not be published",
@@ -454,43 +350,3 @@ func TestCronTool_ExecuteJobReturnsErrorWithoutPublish(t *testing.T) {
t.Fatalf("unexpected publish on error path: %q", executor.publishedResp)
}
}
-
-func TestCronTool_AddJobRejectsInvalidType(t *testing.T) {
- tool := newTestCronTool(t)
- ctx := WithToolContext(context.Background(), "cli", "direct")
- result := tool.Execute(ctx, map[string]any{
- "action": "add",
- "message": "test",
- "at_seconds": float64(60),
- "type": "invalid_type",
- })
-
- if !result.IsError {
- t.Fatal("expected error for invalid type parameter")
- }
- if !strings.Contains(result.ForLLM, "invalid type") {
- t.Errorf("expected 'invalid type' error, got: %s", result.ForLLM)
- }
-}
-
-func TestCronTool_AddJobAcceptsValidTypes(t *testing.T) {
- for _, msgType := range []string{"", "message", "directive"} {
- t.Run("type="+msgType, func(t *testing.T) {
- tool := newTestCronTool(t)
- ctx := WithToolContext(context.Background(), "cli", "direct")
- args := map[string]any{
- "action": "add",
- "message": "test",
- "at_seconds": float64(60),
- }
- if msgType != "" {
- args["type"] = msgType
- }
-
- result := tool.Execute(ctx, args)
- if result.IsError {
- t.Fatalf("expected valid type %q to succeed, got: %s", msgType, result.ForLLM)
- }
- })
- }
-}
diff --git a/pkg/tools/delegate.go b/pkg/tools/delegate.go
new file mode 100644
index 000000000..dcde27718
--- /dev/null
+++ b/pkg/tools/delegate.go
@@ -0,0 +1,104 @@
+package tools
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/routing"
+)
+
+// DelegateTool delegates a task to a specific named agent and waits for
+// the result. Unlike spawn (async, fire-and-forget) or subagent (sync but
+// generic), delegate targets a named agent and runs the task using that
+// agent's own workspace, model, and tools.
+type DelegateTool struct {
+ spawner SubTurnSpawner
+ allowlistCheck func(targetAgentID string) bool
+ selfAgentID string
+}
+
+func NewDelegateTool() *DelegateTool {
+ return &DelegateTool{}
+}
+
+func (t *DelegateTool) SetSpawner(spawner SubTurnSpawner) {
+ t.spawner = spawner
+}
+
+func (t *DelegateTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
+ t.allowlistCheck = check
+}
+
+func (t *DelegateTool) SetSelfAgentID(id string) {
+ t.selfAgentID = id
+}
+
+func (t *DelegateTool) Name() string {
+ return "delegate"
+}
+
+func (t *DelegateTool) Description() string {
+ return "Delegate a task to another agent and wait for the result. " +
+ "Use this when another agent is better suited to handle a specific task " +
+ "based on their capabilities. The target agent runs with its own workspace, " +
+ "model, and tools."
+}
+
+func (t *DelegateTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "agent_id": map[string]any{
+ "type": "string",
+ "description": "The ID of the target agent to delegate the task to",
+ },
+ "task": map[string]any{
+ "type": "string",
+ "description": "Clear description of the task to delegate",
+ },
+ },
+ "required": []string{"agent_id", "task"},
+ }
+}
+
+func (t *DelegateTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ rawAgentID, _ := args["agent_id"].(string)
+ if strings.TrimSpace(rawAgentID) == "" {
+ return ErrorResult("agent_id is required and must be a non-empty string")
+ }
+ agentID := routing.NormalizeAgentID(rawAgentID)
+
+ task, _ := args["task"].(string)
+ if strings.TrimSpace(task) == "" {
+ return ErrorResult("task is required and must be a non-empty string")
+ }
+
+ if t.selfAgentID != "" && agentID == t.selfAgentID {
+ return ErrorResult("cannot delegate to self")
+ }
+
+ if t.allowlistCheck != nil && !t.allowlistCheck(agentID) {
+ return ErrorResult(fmt.Sprintf("not allowed to delegate to agent %q", agentID))
+ }
+
+ if t.spawner == nil {
+ return ErrorResult("delegate tool not configured")
+ }
+
+ result, err := t.spawner.SpawnSubTurn(ctx, SubTurnConfig{
+ TargetAgentID: agentID,
+ SystemPrompt: task,
+ Async: false,
+ })
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("delegation to agent %q failed: %v", agentID, err)).WithError(err)
+ }
+ if result == nil {
+ return ErrorResult(fmt.Sprintf("delegation to agent %q returned no result", agentID))
+ }
+
+ result.ForLLM = fmt.Sprintf("[Response from agent %q]\n%s", agentID, result.ForLLM)
+
+ return result
+}
diff --git a/pkg/tools/delegate_test.go b/pkg/tools/delegate_test.go
new file mode 100644
index 000000000..729c524a7
--- /dev/null
+++ b/pkg/tools/delegate_test.go
@@ -0,0 +1,300 @@
+package tools
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+)
+
+// delegateMockSpawner records the config and returns a canned result.
+type delegateMockSpawner struct {
+ lastCfg SubTurnConfig
+ result *ToolResult
+ err error
+}
+
+func (m *delegateMockSpawner) SpawnSubTurn(_ context.Context, cfg SubTurnConfig) (*ToolResult, error) {
+ m.lastCfg = cfg
+ if m.err != nil {
+ return nil, m.err
+ }
+ if m.result != nil {
+ return m.result, nil
+ }
+ return &ToolResult{
+ ForLLM: "completed: " + cfg.SystemPrompt,
+ ForUser: "completed",
+ }, nil
+}
+
+func TestDelegateTool_Name(t *testing.T) {
+ tool := NewDelegateTool()
+ if tool.Name() != "delegate" {
+ t.Errorf("Name() = %q, want %q", tool.Name(), "delegate")
+ }
+}
+
+func TestDelegateTool_Parameters(t *testing.T) {
+ tool := NewDelegateTool()
+ params := tool.Parameters()
+
+ props, ok := params["properties"].(map[string]any)
+ if !ok {
+ t.Fatal("properties should be a map")
+ }
+ _, hasAgentID := props["agent_id"]
+ if !hasAgentID {
+ t.Error("agent_id parameter should exist")
+ }
+ _, hasTask := props["task"]
+ if !hasTask {
+ t.Error("task parameter should exist")
+ }
+
+ required, ok := params["required"].([]string)
+ if !ok {
+ t.Fatal("required should be a string array")
+ }
+ if len(required) != 2 {
+ t.Fatalf("required should have 2 entries, got %d", len(required))
+ }
+}
+
+func TestDelegateTool_Execute_Success(t *testing.T) {
+ spawner := &delegateMockSpawner{}
+ tool := NewDelegateTool()
+ tool.SetSpawner(spawner)
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": "researcher",
+ "task": "summarize the logs",
+ })
+
+ if result.IsError {
+ t.Fatalf("expected success, got error: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, `[Response from agent "researcher"]`) {
+ t.Errorf("result should contain attribution, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "summarize the logs") {
+ t.Errorf("result should contain task output, got: %s", result.ForLLM)
+ }
+
+ // Verify spawner received correct config
+ if spawner.lastCfg.TargetAgentID != "researcher" {
+ t.Errorf("TargetAgentID = %q, want %q", spawner.lastCfg.TargetAgentID, "researcher")
+ }
+ if spawner.lastCfg.Async {
+ t.Error("delegate should be synchronous (Async=false)")
+ }
+ if spawner.lastCfg.SystemPrompt != "summarize the logs" {
+ t.Errorf("SystemPrompt = %q, want %q", spawner.lastCfg.SystemPrompt, "summarize the logs")
+ }
+}
+
+func TestDelegateTool_Execute_EmptyAgentID(t *testing.T) {
+ tests := []struct {
+ name string
+ args map[string]any
+ }{
+ {"missing", map[string]any{"task": "test"}},
+ {"empty string", map[string]any{"agent_id": "", "task": "test"}},
+ {"whitespace only", map[string]any{"agent_id": " ", "task": "test"}},
+ {"wrong type", map[string]any{"agent_id": 123, "task": "test"}},
+ }
+
+ tool := NewDelegateTool()
+ tool.SetSpawner(&delegateMockSpawner{})
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tool.Execute(context.Background(), tt.args)
+ if !result.IsError {
+ t.Error("expected error for invalid agent_id")
+ }
+ if !strings.Contains(result.ForLLM, "agent_id is required") {
+ t.Errorf("error should mention agent_id, got: %s", result.ForLLM)
+ }
+ })
+ }
+}
+
+func TestDelegateTool_Execute_EmptyTask(t *testing.T) {
+ tests := []struct {
+ name string
+ args map[string]any
+ }{
+ {"missing", map[string]any{"agent_id": "a"}},
+ {"empty string", map[string]any{"agent_id": "a", "task": ""}},
+ {"whitespace only", map[string]any{"agent_id": "a", "task": "\t\n"}},
+ }
+
+ tool := NewDelegateTool()
+ tool.SetSpawner(&delegateMockSpawner{})
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tool.Execute(context.Background(), tt.args)
+ if !result.IsError {
+ t.Error("expected error for invalid task")
+ }
+ if !strings.Contains(result.ForLLM, "task is required") {
+ t.Errorf("error should mention task, got: %s", result.ForLLM)
+ }
+ })
+ }
+}
+
+func TestDelegateTool_Execute_PermissionDenied(t *testing.T) {
+ tool := NewDelegateTool()
+ tool.SetSpawner(&delegateMockSpawner{})
+ tool.SetAllowlistChecker(func(targetAgentID string) bool {
+ return targetAgentID == "allowed-agent"
+ })
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": "forbidden-agent",
+ "task": "test",
+ })
+
+ if !result.IsError {
+ t.Error("expected error for denied agent")
+ }
+ if !strings.Contains(result.ForLLM, "not allowed to delegate") {
+ t.Errorf("error should mention permission, got: %s", result.ForLLM)
+ }
+}
+
+func TestDelegateTool_Execute_PermissionAllowed(t *testing.T) {
+ tool := NewDelegateTool()
+ tool.SetSpawner(&delegateMockSpawner{})
+ tool.SetAllowlistChecker(func(targetAgentID string) bool {
+ return targetAgentID == "allowed-agent"
+ })
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": "allowed-agent",
+ "task": "test",
+ })
+
+ if result.IsError {
+ t.Errorf("expected success for allowed agent, got error: %s", result.ForLLM)
+ }
+}
+
+func TestDelegateTool_Execute_NoSpawner(t *testing.T) {
+ tool := NewDelegateTool()
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": "a",
+ "task": "test",
+ })
+
+ if !result.IsError {
+ t.Error("expected error when spawner is nil")
+ }
+ if !strings.Contains(result.ForLLM, "not configured") {
+ t.Errorf("error should mention not configured, got: %s", result.ForLLM)
+ }
+}
+
+func TestDelegateTool_Execute_SpawnerError(t *testing.T) {
+ spawner := &delegateMockSpawner{
+ err: fmt.Errorf("context deadline exceeded"),
+ }
+ tool := NewDelegateTool()
+ tool.SetSpawner(spawner)
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": "researcher",
+ "task": "test",
+ })
+
+ if !result.IsError {
+ t.Error("expected error when spawner fails")
+ }
+ if !strings.Contains(result.ForLLM, "delegation to agent") {
+ t.Errorf("error should mention delegation failure, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "context deadline exceeded") {
+ t.Errorf("error should propagate cause, got: %s", result.ForLLM)
+ }
+}
+
+func TestDelegateTool_Execute_NoAllowlistCheck(t *testing.T) {
+ // When no allowlist checker is set, all agents are allowed
+ tool := NewDelegateTool()
+ tool.SetSpawner(&delegateMockSpawner{})
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": "any-agent",
+ "task": "test",
+ })
+
+ if result.IsError {
+ t.Errorf("expected success without allowlist, got error: %s", result.ForLLM)
+ }
+}
+
+func TestDelegateTool_Execute_NilResult(t *testing.T) {
+ tool := NewDelegateTool()
+ tool.SetSpawner(&nilResultSpawner{})
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": "researcher",
+ "task": "test",
+ })
+
+ if !result.IsError {
+ t.Error("expected error for nil result")
+ }
+ if !strings.Contains(result.ForLLM, "returned no result") {
+ t.Errorf("error should mention no result, got: %s", result.ForLLM)
+ }
+}
+
+func TestDelegateTool_Execute_SelfDelegation(t *testing.T) {
+ tool := NewDelegateTool()
+ tool.SetSpawner(&delegateMockSpawner{})
+ tool.SetSelfAgentID("alpha")
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": "alpha",
+ "task": "test",
+ })
+
+ if !result.IsError {
+ t.Error("expected error for self-delegation")
+ }
+ if !strings.Contains(result.ForLLM, "cannot delegate to self") {
+ t.Errorf("error should mention self-delegation, got: %s", result.ForLLM)
+ }
+}
+
+func TestDelegateTool_Execute_SelfDelegation_Normalized(t *testing.T) {
+ tool := NewDelegateTool()
+ tool.SetSpawner(&delegateMockSpawner{})
+ tool.SetSelfAgentID("alpha") // stored normalized
+
+ // Case-insensitive and whitespace variants should still be caught
+ variants := []string{"ALPHA", " Alpha ", " alpha "}
+ for _, v := range variants {
+ t.Run(v, func(t *testing.T) {
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": v,
+ "task": "test",
+ })
+ if !result.IsError {
+ t.Errorf("agent_id=%q should be caught as self-delegation", v)
+ }
+ })
+ }
+}
+
+// nilResultSpawner always returns (nil, nil).
+type nilResultSpawner struct{}
+
+func (m *nilResultSpawner) SpawnSubTurn(_ context.Context, _ SubTurnConfig) (*ToolResult, error) {
+ return nil, nil
+}
diff --git a/pkg/tools/facade_compat_test.go b/pkg/tools/facade_compat_test.go
new file mode 100644
index 000000000..378462512
--- /dev/null
+++ b/pkg/tools/facade_compat_test.go
@@ -0,0 +1,18 @@
+package tools
+
+import "testing"
+
+func TestFacadeConstructorsRemainAvailable(t *testing.T) {
+ if NewI2CTool() == nil {
+ t.Fatal("NewI2CTool should return a tool")
+ }
+ if NewSPITool() == nil {
+ t.Fatal("NewSPITool should return a tool")
+ }
+ if NewSerialTool() == nil {
+ t.Fatal("NewSerialTool should return a tool")
+ }
+ if NewMessageTool() == nil {
+ t.Fatal("NewMessageTool should return a tool")
+ }
+}
diff --git a/pkg/tools/edit.go b/pkg/tools/fs/edit.go
similarity index 86%
rename from pkg/tools/edit.go
rename to pkg/tools/fs/edit.go
index d5bebf4a2..827ea50c8 100644
--- a/pkg/tools/edit.go
+++ b/pkg/tools/fs/edit.go
@@ -1,4 +1,4 @@
-package tools
+package fstools
import (
"context"
@@ -29,7 +29,7 @@ func (t *EditFileTool) Name() string {
}
func (t *EditFileTool) Description() string {
- return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file."
+ return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n."
}
func (t *EditFileTool) Parameters() map[string]any {
@@ -42,11 +42,11 @@ func (t *EditFileTool) Parameters() map[string]any {
},
"old_text": map[string]any{
"type": "string",
- "description": "The exact text to find and replace",
+ "description": "The exact text to find and replace. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
},
"new_text": map[string]any{
"type": "string",
- "description": "The text to replace with",
+ "description": "The text to replace with. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
},
},
"required": []string{"path", "old_text", "new_text"},
@@ -92,7 +92,7 @@ func (t *AppendFileTool) Name() string {
}
func (t *AppendFileTool) Description() string {
- return "Append content to the end of a file"
+ return "Append content to the end of a file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n."
}
func (t *AppendFileTool) Parameters() map[string]any {
@@ -105,7 +105,7 @@ func (t *AppendFileTool) Parameters() map[string]any {
},
"content": map[string]any{
"type": "string",
- "description": "The content to append",
+ "description": "The content to append. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
},
},
"required": []string{"path", "content"},
diff --git a/pkg/tools/edit_test.go b/pkg/tools/fs/edit_test.go
similarity index 99%
rename from pkg/tools/edit_test.go
rename to pkg/tools/fs/edit_test.go
index 83a7e778c..4c25322ef 100644
--- a/pkg/tools/edit_test.go
+++ b/pkg/tools/fs/edit_test.go
@@ -1,4 +1,4 @@
-package tools
+package fstools
import (
"context"
diff --git a/pkg/tools/filesystem.go b/pkg/tools/fs/filesystem.go
similarity index 69%
rename from pkg/tools/filesystem.go
rename to pkg/tools/fs/filesystem.go
index 39d45013d..262d88d99 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/fs/filesystem.go
@@ -1,18 +1,22 @@
-package tools
+package fstools
import (
+ "bufio"
+ "bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"math"
+ "net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
+ "unicode/utf8"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -20,7 +24,23 @@ import (
const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow
-func validatePathWithAllowPaths(path, workspace string, restrict bool, patterns []*regexp.Regexp) (string, error) {
+func ValidatePathWithAllowPaths(
+ path, workspace string,
+ restrict bool,
+ patterns []*regexp.Regexp,
+) (string, error) {
+ return validatePathWithAllowPaths(path, workspace, restrict, patterns)
+}
+
+func IsAllowedPath(path string, patterns []*regexp.Regexp) bool {
+ return isAllowedPath(path, patterns)
+}
+
+func validatePathWithAllowPaths(
+ path, workspace string,
+ restrict bool,
+ patterns []*regexp.Regexp,
+) (string, error) {
if workspace == "" {
return path, fmt.Errorf("workspace is not defined")
}
@@ -253,6 +273,11 @@ type ReadFileTool struct {
maxSize int64
}
+type ReadFileLinesTool struct {
+ fs fileSystem
+ maxSize int64
+}
+
func NewReadFileTool(
workspace string,
restrict bool,
@@ -275,14 +300,53 @@ func NewReadFileTool(
}
}
+func NewReadFileBytesTool(
+ workspace string,
+ restrict bool,
+ maxReadFileSize int,
+ allowPaths ...[]*regexp.Regexp,
+) *ReadFileTool {
+ return NewReadFileTool(workspace, restrict, maxReadFileSize, allowPaths...)
+}
+
+func NewReadFileLinesTool(
+ workspace string,
+ restrict bool,
+ maxReadFileSize int,
+ allowPaths ...[]*regexp.Regexp,
+) *ReadFileLinesTool {
+ var patterns []*regexp.Regexp
+ if len(allowPaths) > 0 {
+ patterns = allowPaths[0]
+ }
+
+ maxSize := int64(maxReadFileSize)
+ if maxSize <= 0 {
+ maxSize = MaxReadFileSize
+ }
+
+ return &ReadFileLinesTool{
+ fs: buildFs(workspace, restrict, patterns),
+ maxSize: maxSize,
+ }
+}
+
func (t *ReadFileTool) Name() string {
return "read_file"
}
+func (t *ReadFileLinesTool) Name() string {
+ return "read_file"
+}
+
func (t *ReadFileTool) Description() string {
return "Read the contents of a file. Supports pagination via `offset` and `length`."
}
+func (t *ReadFileLinesTool) Description() string {
+ return "Read a UTF-8 text file from the filesystem. Output always includes line numbers in the format `LINE_NUMBER|LINE_CONTENT` (1-indexed). Supports partial reads via `start_line` and `max_lines` for large text files."
+}
+
func (t *ReadFileTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
@@ -306,6 +370,28 @@ func (t *ReadFileTool) Parameters() map[string]any {
}
}
+func (t *ReadFileLinesTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "path": map[string]any{
+ "type": "string",
+ "description": "Path to the file to read.",
+ },
+ "start_line": map[string]any{
+ "type": "integer",
+ "description": "Line number to start reading from (1-indexed, inclusive).",
+ "default": 1,
+ },
+ "max_lines": map[string]any{
+ "type": "integer",
+ "description": "Maximum number of lines to read.",
+ },
+ },
+ "required": []string{"path"},
+ }
+}
+
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
@@ -447,6 +533,302 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return NewToolResult(header + "\n\n" + string(data))
}
+func (t *ReadFileLinesTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ path, ok := args["path"].(string)
+ if !ok {
+ return ErrorResult("path is required")
+ }
+
+ startLine, err := getInt64Arg(args, "start_line", 1)
+ if err != nil {
+ return ErrorResult(err.Error())
+ }
+ if startLine < 1 {
+ return ErrorResult("start_line must be >= 1")
+ }
+ if _, exists := args["offset"]; exists {
+ return ErrorResult("offset is not supported in line mode; use start_line")
+ }
+ if _, exists := args["length"]; exists {
+ return ErrorResult("length is not supported in line mode; use max_lines")
+ }
+ if _, exists := args["limit"]; exists {
+ return ErrorResult("limit is not supported in line mode; use max_lines")
+ }
+
+ limit := int64(-1)
+ if raw, exists := args["max_lines"]; exists && raw != nil {
+ limit, err = getInt64Arg(args, "max_lines", -1)
+ if err != nil {
+ return ErrorResult(err.Error())
+ }
+ if limit <= 0 {
+ return ErrorResult("max_lines, if provided, must be > 0")
+ }
+ }
+
+ file, err := t.fs.Open(path)
+ if err != nil {
+ return ErrorResult(err.Error())
+ }
+ defer file.Close()
+
+ if info, statErr := file.Stat(); statErr == nil && info.IsDir() {
+ return ErrorResult(fmt.Sprintf("failed to open file: path is a directory: %s", path))
+ }
+
+ sample := make([]byte, 512)
+ sampleN, readErr := file.Read(sample)
+ if readErr != nil && readErr != io.EOF {
+ return ErrorResult(fmt.Sprintf("failed to read file: %v", readErr))
+ }
+ sample = sample[:sampleN]
+ if isBinaryReadFileData(sample) {
+ return ErrorResult("file appears to be binary; switch read_file mode to 'bytes' for byte-based inspection")
+ }
+
+ reader := bufio.NewReaderSize(io.MultiReader(bytes.NewReader(sample), file), 32*1024)
+
+ var content strings.Builder
+ lineIndex := int64(1)
+ var linesRead int64
+ var fileBytesRead int64
+ var outputBytesRead int64
+ var reachedEOF bool
+ var byteBudgetTruncated bool
+ var lineTruncated bool
+
+ for lineIndex < startLine {
+ hasLine, consumeErr := consumeNextLine(reader)
+ if consumeErr != nil {
+ return ErrorResult(fmt.Sprintf("failed to read file content: %v", consumeErr))
+ }
+ if !hasLine {
+ reachedEOF = true
+ break
+ }
+ lineIndex++
+ }
+
+ for !reachedEOF && (limit < 0 || linesRead < limit) {
+ prefix := formatReadFileLinePrefix(lineIndex)
+ remaining := t.maxSize - outputBytesRead - int64(len(prefix))
+ if remaining <= 0 {
+ byteBudgetTruncated = true
+ break
+ }
+
+ line, complete, hasLine, readLineErr := readNextLinePrefix(reader, remaining)
+ if readLineErr != nil {
+ return ErrorResult(fmt.Sprintf("failed to read file content: %v", readLineErr))
+ }
+ if !hasLine {
+ reachedEOF = true
+ break
+ }
+
+ content.WriteString(prefix)
+ content.Write(line)
+ fileBytesRead += int64(len(line))
+ outputBytesRead += int64(len(prefix) + len(line))
+ linesRead++
+ lineIndex++
+
+ if !complete {
+ byteBudgetTruncated = true
+ lineTruncated = true
+ break
+ }
+ }
+
+ if !reachedEOF && !lineTruncated {
+ hasMoreContent, peekErr := readerHasMoreContent(reader)
+ if peekErr != nil {
+ return ErrorResult(fmt.Sprintf("failed to inspect remaining file content: %v", peekErr))
+ }
+ if !hasMoreContent {
+ reachedEOF = true
+ byteBudgetTruncated = false
+ }
+ }
+
+ if linesRead == 0 && content.Len() == 0 {
+ return NewToolResult(fmt.Sprintf("[END OF FILE - no content at or after start_line=%d]", startLine))
+ }
+
+ start := startLine
+ endLine := startLine + linesRead - 1
+ displayPath := filepath.Base(path)
+ header := fmt.Sprintf(
+ "[file: %s | read: lines %d-%d (1-indexed) | file_bytes: %d | output_bytes: %d]",
+ displayPath, start, endLine, fileBytesRead, outputBytesRead,
+ )
+
+ switch {
+ case lineTruncated:
+ header += fmt.Sprintf(
+ "\n[TRUNCATED - line %d exceeded the %d byte read budget and was cut mid-line.]",
+ endLine,
+ t.maxSize,
+ )
+ case byteBudgetTruncated:
+ if limit > 0 {
+ header += fmt.Sprintf(
+ "\n[TRUNCATED - byte budget reached. Call read_file again with start_line=%d and max_lines=%d to continue at the next line.]",
+ startLine+linesRead,
+ limit,
+ )
+ } else {
+ header += fmt.Sprintf(
+ "\n[TRUNCATED - byte budget reached. Call read_file again with start_line=%d to continue at the next line.]",
+ startLine+linesRead,
+ )
+ }
+ case !reachedEOF && limit > 0 && linesRead >= limit:
+ header += fmt.Sprintf(
+ "\n[PARTIAL - more content remains. Call read_file again with start_line=%d and max_lines=%d to continue.]",
+ startLine+linesRead,
+ limit,
+ )
+ default:
+ header += "\n[END OF FILE - no further content.]"
+ }
+
+ logger.DebugCF("tool", "ReadFileTool execution completed successfully",
+ map[string]any{
+ "path": path,
+ "lines_read": linesRead,
+ "file_bytes_read": fileBytesRead,
+ "output_bytes_read": outputBytesRead,
+ "truncated": byteBudgetTruncated,
+ "tool": t.Name(),
+ })
+
+ return NewToolResult(header + "\n\n" + content.String())
+}
+
+func formatReadFileLinePrefix(lineNumber int64) string {
+ return strconv.FormatInt(lineNumber, 10) + "|"
+}
+
+func isBinaryReadFileData(data []byte) bool {
+ if len(data) == 0 {
+ return false
+ }
+
+ sample := data
+ if len(sample) > 512 {
+ sample = sample[:512]
+ }
+
+ if bytes.IndexByte(sample, 0) >= 0 {
+ return true
+ }
+
+ contentType := http.DetectContentType(sample)
+ if strings.HasPrefix(contentType, "text/") {
+ return false
+ }
+ if strings.HasSuffix(contentType, "/json") ||
+ strings.HasSuffix(contentType, "+json") ||
+ strings.HasSuffix(contentType, "/xml") ||
+ strings.HasSuffix(contentType, "+xml") ||
+ strings.Contains(contentType, "javascript") {
+ return false
+ }
+
+ if !utf8.Valid(sample) {
+ return true
+ }
+
+ controlChars := 0
+ for _, b := range sample {
+ if b < 0x20 && b != '\n' && b != '\r' && b != '\t' && b != '\f' && b != '\b' {
+ controlChars++
+ }
+ }
+
+ return float64(controlChars)/float64(len(sample)) > 0.1
+}
+
+func consumeNextLine(reader *bufio.Reader) (bool, error) {
+ sawData := false
+
+ for {
+ fragment, err := reader.ReadSlice('\n')
+ if len(fragment) > 0 {
+ sawData = true
+ }
+
+ switch {
+ case err == nil:
+ return true, nil
+ case errors.Is(err, bufio.ErrBufferFull):
+ continue
+ case errors.Is(err, io.EOF):
+ return sawData, nil
+ default:
+ return false, err
+ }
+ }
+}
+
+func readNextLinePrefix(reader *bufio.Reader, maxBytes int64) ([]byte, bool, bool, error) {
+ if maxBytes <= 0 {
+ return nil, false, false, nil
+ }
+
+ var out bytes.Buffer
+ sawData := false
+ complete := true
+
+ for {
+ fragment, err := reader.ReadSlice('\n')
+ if len(fragment) > 0 {
+ sawData = true
+ if remaining := maxBytes - int64(out.Len()); remaining > 0 {
+ take := len(fragment)
+ if int64(take) > remaining {
+ take = int(remaining)
+ complete = false
+ }
+ out.Write(fragment[:take])
+ } else {
+ complete = false
+ }
+ }
+
+ switch {
+ case err == nil:
+ return out.Bytes(), complete, sawData, nil
+ case errors.Is(err, bufio.ErrBufferFull):
+ if !complete {
+ return out.Bytes(), false, true, nil
+ }
+ continue
+ case errors.Is(err, io.EOF):
+ if !sawData {
+ return nil, true, false, nil
+ }
+ return out.Bytes(), complete, true, nil
+ default:
+ return nil, false, false, err
+ }
+ }
+}
+
+func readerHasMoreContent(reader *bufio.Reader) (bool, error) {
+ _, err := reader.Peek(1)
+ switch {
+ case err == nil:
+ return true, nil
+ case errors.Is(err, io.EOF):
+ return false, nil
+ default:
+ return false, err
+ }
+}
+
// getInt64Arg extracts an integer argument from the args map, returning the
// provided default if the key is absent.
func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) {
@@ -483,7 +865,11 @@ type WriteFileTool struct {
fs fileSystem
}
-func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool {
+func NewWriteFileTool(
+ workspace string,
+ restrict bool,
+ allowPaths ...[]*regexp.Regexp,
+) *WriteFileTool {
var patterns []*regexp.Regexp
if len(allowPaths) > 0 {
patterns = allowPaths[0]
@@ -496,7 +882,7 @@ func (t *WriteFileTool) Name() string {
}
func (t *WriteFileTool) Description() string {
- return "Write content to a file. If the file already exists, you must set overwrite=true to replace it."
+ return "Write content to a file. Content is written byte-for-byte after argument decoding. Standard JSON escaping applies: \\n for newline and \\\\n for a literal backslash-n sequence. If the file already exists, you must set overwrite=true to replace it."
}
func (t *WriteFileTool) Parameters() map[string]any {
@@ -509,7 +895,7 @@ func (t *WriteFileTool) Parameters() map[string]any {
},
"content": map[string]any{
"type": "string",
- "description": "Content to write to the file",
+ "description": "Content to write to the file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
},
"overwrite": map[string]any{
"type": "boolean",
@@ -536,7 +922,9 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
if !overwrite {
if _, err := t.fs.Open(path); err == nil {
- return ErrorResult(fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path))
+ return ErrorResult(
+ fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path),
+ )
}
}
diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/fs/filesystem_test.go
similarity index 65%
rename from pkg/tools/filesystem_test.go
rename to pkg/tools/fs/filesystem_test.go
index 0b4dd310b..4387332be 100644
--- a/pkg/tools/filesystem_test.go
+++ b/pkg/tools/fs/filesystem_test.go
@@ -1,4 +1,4 @@
-package tools
+package fstools
import (
"context"
@@ -18,7 +18,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("test content"), 0o644)
- tool := NewReadFileTool("", false, MaxReadFileSize)
+ tool := NewReadFileBytesTool("", false, MaxReadFileSize)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@@ -45,7 +45,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
- tool := NewReadFileTool("", false, MaxReadFileSize)
+ tool := NewReadFileBytesTool("", false, MaxReadFileSize)
ctx := context.Background()
args := map[string]any{
"path": "/nonexistent_file_12345.txt",
@@ -59,8 +59,13 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
}
// Should contain error message
- if !strings.Contains(result.ForLLM, "failed to open file") && !strings.Contains(result.ForUser, "failed to read") {
- t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
+ if !strings.Contains(result.ForLLM, "failed to open file") &&
+ !strings.Contains(result.ForUser, "failed to open") {
+ t.Errorf(
+ "Expected error message, got ForLLM: %s, ForUser: %s",
+ result.ForLLM,
+ result.ForUser,
+ )
}
}
@@ -78,7 +83,8 @@ func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) {
}
// Should mention required parameter
- if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") {
+ if !strings.Contains(result.ForLLM, "path is required") &&
+ !strings.Contains(result.ForUser, "path is required") {
t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM)
}
}
@@ -122,6 +128,45 @@ func TestFilesystemTool_WriteFile_Success(t *testing.T) {
}
}
+// TestFilesystemTool_WriteFile_LiteralBackslashN verifies write_file keeps
+// literal backslash sequences unchanged when they are passed as plain text.
+func TestFilesystemTool_WriteFile_LiteralBackslashN(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "literal.txt")
+
+ tool := NewWriteFileTool("", false)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "content": `aaa\naaa`,
+ })
+
+ assert.False(t, result.IsError, "expected success, got: %s", result.ForLLM)
+
+ data, err := os.ReadFile(testFile)
+ assert.NoError(t, err)
+ assert.Equal(t, `aaa\naaa`, string(data))
+}
+
+// TestFilesystemTool_WriteFile_PreservesCRLF verifies write_file does not
+// normalize line endings and writes CRLF bytes as provided.
+func TestFilesystemTool_WriteFile_PreservesCRLF(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "crlf.txt")
+ content := "line1\r\nline2\r\n"
+
+ tool := NewWriteFileTool("", false)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "content": content,
+ })
+
+ assert.False(t, result.IsError, "expected success, got: %s", result.ForLLM)
+
+ data, err := os.ReadFile(testFile)
+ assert.NoError(t, err)
+ assert.Equal(t, []byte(content), data)
+}
+
// TestFilesystemTool_WriteFile_CreateDir verifies directory creation
func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
tmpDir := t.TempDir()
@@ -297,7 +342,12 @@ func TestFilesystemTool_WriteFile_OverwriteSandboxed(t *testing.T) {
"content": "replaced in sandbox",
"overwrite": true,
})
- assert.False(t, result.IsError, "expected success in sandbox mode with overwrite=true, got: %s", result.ForLLM)
+ assert.False(
+ t,
+ result.IsError,
+ "expected success in sandbox mode with overwrite=true, got: %s",
+ result.ForLLM,
+ )
data, err := os.ReadFile(filepath.Join(workspace, testFile))
assert.NoError(t, err)
@@ -325,7 +375,8 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
}
// Should list files and directories
- if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") {
+ if !strings.Contains(result.ForLLM, "file1.txt") ||
+ !strings.Contains(result.ForLLM, "file2.txt") {
t.Errorf("Expected files in listing, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "subdir") {
@@ -349,8 +400,13 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
}
// Should contain error message
- if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
- t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
+ if !strings.Contains(result.ForLLM, "failed to read") &&
+ !strings.Contains(result.ForUser, "failed to read") {
+ t.Errorf(
+ "Expected error message, got ForLLM: %s, ForUser: %s",
+ result.ForLLM,
+ result.ForUser,
+ )
}
}
@@ -397,7 +453,8 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
// os.Root might return different errors depending on platform/implementation
// but it definitely should error.
// Our wrapper returns "access denied or file not found"
- if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") &&
+ if !strings.Contains(result.ForLLM, "access denied") &&
+ !strings.Contains(result.ForLLM, "file not found") &&
!strings.Contains(result.ForLLM, "no such file") {
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
}
@@ -416,10 +473,20 @@ func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
})
// We EXPECT IsError=true (access blocked due to empty workspace)
- assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM)
+ assert.True(
+ t,
+ result.IsError,
+ "Security Regression: Empty workspace allowed access! content: %s",
+ result.ForLLM,
+ )
// Verify it failed for the right reason
- assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error")
+ assert.Contains(
+ t,
+ result.ForLLM,
+ "workspace is not defined",
+ "Expected 'workspace is not defined' error",
+ )
}
// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases:
@@ -653,7 +720,10 @@ func TestWhitelistFs_BlocksSymlinkEscapeInAllowedDir(t *testing.T) {
patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))}
tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns)
- result := tool.Execute(context.Background(), map[string]any{"path": filepath.Join(linkPath, "secret.txt")})
+ result := tool.Execute(
+ context.Background(),
+ map[string]any{"path": filepath.Join(linkPath, "secret.txt")},
+ )
if !result.IsError {
t.Fatalf("expected symlink escape from allowed dir to be blocked, got: %s", result.ForLLM)
}
@@ -726,7 +796,6 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "pagination_test.txt")
- // Create a test file with exactly 26 bytes of content
fullContent := "abcdefghijklmnopqrstuvwxyz"
err := os.WriteFile(testFile, []byte(fullContent), 0o644)
if err != nil {
@@ -748,15 +817,12 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
t.Fatalf("Chunk 1 failed: %s", result1.ForLLM)
}
- // Expect the first 10 characters
if !strings.Contains(result1.ForLLM, "abcdefghij") {
t.Errorf("Chunk 1 should contain 'abcdefghij', got: %s", result1.ForLLM)
}
- // Expect the header to indicate the file is truncated
if !strings.Contains(result1.ForLLM, "[TRUNCATED") {
t.Errorf("Chunk 1 header should indicate truncation, got: %s", result1.ForLLM)
}
- // Expect the header to suggest the next offset (10)
if !strings.Contains(result1.ForLLM, "offset=10") {
t.Errorf("Chunk 1 header should suggest next offset=10, got: %s", result1.ForLLM)
}
@@ -773,17 +839,14 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
t.Fatalf("Chunk 2 failed: %s", result2.ForLLM)
}
- // Expect the next 10 characters
if !strings.Contains(result2.ForLLM, "klmnopqrst") {
t.Errorf("Chunk 2 should contain 'klmnopqrst', got: %s", result2.ForLLM)
}
- // Expect the header to suggest the next offset (20)
if !strings.Contains(result2.ForLLM, "offset=20") {
t.Errorf("Chunk 2 header should suggest next offset=20, got: %s", result2.ForLLM)
}
// Step 3: Read the final chunk (remaining 6 bytes) ---
- // We ask for 10 bytes, but only 6 are left in the file
args3 := map[string]any{
"path": testFile,
"offset": 20,
@@ -795,16 +858,12 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
t.Fatalf("Chunk 3 failed: %s", result3.ForLLM)
}
- // Expect the last 6 characters
if !strings.Contains(result3.ForLLM, "uvwxyz") {
t.Errorf("Chunk 3 should contain 'uvwxyz', got: %s", result3.ForLLM)
}
- // Expect the header to indicate the end of the file
if !strings.Contains(result3.ForLLM, "[END OF FILE") {
t.Errorf("Chunk 3 header should indicate end of file, got: %s", result3.ForLLM)
}
-
- // Ensure no TRUNCATED message is present in the final chunk
if strings.Contains(result3.ForLLM, "[TRUNCATED") {
t.Errorf("Chunk 3 header should NOT indicate truncation, got: %s", result3.ForLLM)
}
@@ -816,7 +875,6 @@ func TestReadFileTool_OffsetBeyondEOF(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "short.txt")
- // create a file of only 5 bytes
err := os.WriteFile(testFile, []byte("12345"), 0o644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
@@ -827,19 +885,356 @@ func TestReadFileTool_OffsetBeyondEOF(t *testing.T) {
args := map[string]any{
"path": testFile,
- "offset": int64(100), // Offset beyond the end of the file
+ "offset": int64(100),
}
result := tool.Execute(ctx, args)
- // It should not be classified as a tool execution error
if result.IsError {
t.Errorf("A mistake was not expected, obtained IsError=true: %s", result.ForLLM)
}
- // Must return EXACTLY the string provided in the code
expectedMsg := "[END OF FILE - no content at this offset]"
if result.ForLLM != expectedMsg {
t.Errorf("The message %q was expected, obtained: %q", expectedMsg, result.ForLLM)
}
}
+
+func TestReadFileLinesTool_ChunkedReading(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "pagination_lines.txt")
+
+ fullContent := strings.Join([]string{
+ "line 1",
+ "line 2",
+ "line 3",
+ "line 4",
+ "line 5",
+ "line 6",
+ }, "\n") + "\n"
+ err := os.WriteFile(testFile, []byte(fullContent), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+
+ result1 := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ "max_lines": 2,
+ })
+ if result1.IsError {
+ t.Fatalf("Chunk 1 failed: %s", result1.ForLLM)
+ }
+ if !strings.Contains(result1.ForLLM, "1|line 1\n2|line 2\n") {
+ t.Fatalf("expected first two lines, got: %s", result1.ForLLM)
+ }
+ if !strings.Contains(result1.ForLLM, "lines 1-2") {
+ t.Fatalf("expected line range 1-2, got: %s", result1.ForLLM)
+ }
+ if !strings.Contains(result1.ForLLM, "start_line=3") {
+ t.Fatalf("expected continuation start_line=3, got: %s", result1.ForLLM)
+ }
+ if !strings.Contains(result1.ForLLM, "max_lines=2") {
+ t.Fatalf("expected continuation max_lines=2, got: %s", result1.ForLLM)
+ }
+
+ result2 := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 3,
+ "max_lines": 2,
+ })
+ if result2.IsError {
+ t.Fatalf("Chunk 2 failed: %s", result2.ForLLM)
+ }
+ if !strings.Contains(result2.ForLLM, "3|line 3\n4|line 4\n") {
+ t.Fatalf("expected middle chunk, got: %s", result2.ForLLM)
+ }
+ if !strings.Contains(result2.ForLLM, "start_line=5") {
+ t.Fatalf("expected continuation start_line=5, got: %s", result2.ForLLM)
+ }
+ if !strings.Contains(result2.ForLLM, "max_lines=2") {
+ t.Fatalf("expected continuation max_lines=2, got: %s", result2.ForLLM)
+ }
+
+ result3 := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 5,
+ "max_lines": 2,
+ })
+ if result3.IsError {
+ t.Fatalf("Chunk 3 failed: %s", result3.ForLLM)
+ }
+ if !strings.Contains(result3.ForLLM, "5|line 5\n6|line 6\n") {
+ t.Fatalf("expected final chunk, got: %s", result3.ForLLM)
+ }
+ if !strings.Contains(result3.ForLLM, "[END OF FILE") {
+ t.Fatalf("expected EOF marker, got: %s", result3.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_DefaultOffsetAndRemainingLines(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "default_lines.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ })
+ if result.IsError {
+ t.Fatalf("Execute() error = %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "1|line 1\n2|line 2\n3|line 3\n") {
+ t.Fatalf("expected remaining lines by default, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "lines 1-3") {
+ t.Fatalf("expected line range 1-3, got: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileTool_LegacyLengthUsesByteModeForText(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "legacy_bytes.txt")
+
+ err := os.WriteFile(testFile, []byte("abcdefghijklmnopqrstuvwxyz"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileBytesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "offset": 10,
+ "length": 5,
+ })
+ if result.IsError {
+ t.Fatalf("Execute() error = %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "read: bytes 10-14") {
+ t.Fatalf("expected byte-based header, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "klmno") {
+ t.Fatalf("expected byte chunk content, got: %s", result.ForLLM)
+ }
+ if strings.Contains(result.ForLLM, "lines ") {
+ t.Fatalf("expected legacy byte mode, got line-based header: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_OffsetBeyondEOF(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "short_lines.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": int64(100),
+ })
+ if result.IsError {
+ t.Fatalf("unexpected error: %s", result.ForLLM)
+ }
+ if result.ForLLM != "[END OF FILE - no content at or after start_line=100]" {
+ t.Fatalf("unexpected EOF message: %q", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_RejectsOffset(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "legacy_offset.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ "offset": 1,
+ })
+ if !result.IsError {
+ t.Fatalf("expected offset to be rejected, got success: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "offset is not supported in line mode; use start_line") {
+ t.Fatalf("unexpected error for offset in line mode: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_RejectsLength(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "legacy_length.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ "length": 1,
+ })
+ if !result.IsError {
+ t.Fatalf("expected length to be rejected, got success: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "length is not supported in line mode; use max_lines") {
+ t.Fatalf("unexpected error for length in line mode: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_RejectsLimit(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "legacy_limit.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ "limit": 1,
+ })
+ if !result.IsError {
+ t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "limit is not supported in line mode; use max_lines") {
+ t.Fatalf("unexpected error for limit in line mode: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_BinaryFileRejected(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "binary.dat")
+
+ data := []byte{0x00, 0x01, 'A', 'B', 'C', 'D', 'E', 'F'}
+ err := os.WriteFile(testFile, data, 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ })
+ if !result.IsError {
+ t.Fatalf("expected binary file rejection in line mode, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "switch read_file mode to 'bytes'") {
+ t.Fatalf("expected binary file rejection message, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "mode to 'bytes'") {
+ t.Fatalf("expected suggestion to switch read_file mode, got: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_TruncatesSingleLongLineAtByteBudget(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "long_line.txt")
+
+ content := "first line\n" + strings.Repeat("x", 70*1024) + "\n"
+ err := os.WriteFile(testFile, []byte(content), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ })
+ if result.IsError {
+ t.Fatalf("Execute() error = %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "was cut mid-line") {
+ t.Fatalf("expected explicit mid-line truncation warning, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "1|first line\n") {
+ t.Fatalf("expected the first line with line prefix, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "2|") {
+ t.Fatalf("expected line prefix for the truncated line, got: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_NoTrailingNewline(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "no_trailing_newline.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ })
+ if result.IsError {
+ t.Fatalf("Execute() error = %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "1|line 1\n2|line 2") {
+ t.Fatalf(
+ "expected final line without trailing newline to be preserved, got: %s",
+ result.ForLLM,
+ )
+ }
+ if !strings.Contains(result.ForLLM, "[END OF FILE - no further content.]") {
+ t.Fatalf("expected EOF marker, got: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_ExactByteBudgetBoundaryIncludesPrefix(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "exact_boundary.txt")
+
+ err := os.WriteFile(testFile, []byte("1234567\nsecond line\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, 10)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ })
+ if result.IsError {
+ t.Fatalf("Execute() error = %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "1|1234567\n") {
+ t.Fatalf(
+ "expected first line to fit exactly in the byte budget with its prefix, got: %s",
+ result.ForLLM,
+ )
+ }
+ if strings.Contains(result.ForLLM, "2|") {
+ t.Fatalf(
+ "expected second line to be excluded once the exact output byte budget was reached, got: %s",
+ result.ForLLM,
+ )
+ }
+ if !strings.Contains(result.ForLLM, "file_bytes: 8 | output_bytes: 10") {
+ t.Fatalf("expected separate file/output byte counters, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "start_line=2") {
+ t.Fatalf("expected continuation at line 2, got: %s", result.ForLLM)
+ }
+}
diff --git a/pkg/tools/fs/load_image.go b/pkg/tools/fs/load_image.go
new file mode 100644
index 000000000..0a67fa120
--- /dev/null
+++ b/pkg/tools/fs/load_image.go
@@ -0,0 +1,163 @@
+package fstools
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/media"
+)
+
+// LoadImageTool loads a local image file into the MediaStore and returns a
+// media:// reference. The agent loop's resolveMediaRefs will then base64-encode
+// it and attach it as an image_url part in the next LLM request, enabling
+// vision on local files — the same pipeline used when a user sends an image
+// through a chat channel.
+//
+// This is intentionally different from SendFileTool:
+// - SendFileTool → MediaResult + WithResponseHandled() → sends file to user, ends turn
+// - LoadImageTool → plain ToolResult with media:// in ForLLM → LLM sees the image next turn
+type LoadImageTool struct {
+ workspace string
+ restrict bool
+ maxFileSize int
+ mediaStore media.MediaStore
+ allowPaths []*regexp.Regexp
+
+ defaultChannel string
+ defaultChatID string
+}
+
+func NewLoadImageTool(
+ workspace string,
+ restrict bool,
+ maxFileSize int,
+ store media.MediaStore,
+ allowPaths ...[]*regexp.Regexp,
+) *LoadImageTool {
+ if maxFileSize <= 0 {
+ maxFileSize = config.DefaultMaxMediaSize
+ }
+ var patterns []*regexp.Regexp
+ if len(allowPaths) > 0 {
+ patterns = allowPaths[0]
+ }
+ return &LoadImageTool{
+ workspace: workspace,
+ restrict: restrict,
+ maxFileSize: maxFileSize,
+ mediaStore: store,
+ allowPaths: patterns,
+ }
+}
+
+func (t *LoadImageTool) Name() string { return "load_image" }
+
+func (t *LoadImageTool) Description() string {
+ return "Load a local image file so you can analyze its contents with vision. " +
+ "Supported formats: JPEG, PNG, GIF, WebP, BMP. " +
+ "After calling this tool, describe or analyze the image in your next response."
+}
+
+func (t *LoadImageTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "path": map[string]any{
+ "type": "string",
+ "description": "Path to the local image file. Relative paths are resolved from workspace.",
+ },
+ },
+ "required": []string{"path"},
+ }
+}
+
+func (t *LoadImageTool) SetContext(channel, chatID string) {
+ t.defaultChannel = channel
+ t.defaultChatID = chatID
+}
+
+func (t *LoadImageTool) SetMediaStore(store media.MediaStore) {
+ t.mediaStore = store
+}
+
+func (t *LoadImageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ path, _ := args["path"].(string)
+ if strings.TrimSpace(path) == "" {
+ return ErrorResult("path is required")
+ }
+
+ // Prefer context-injected channel/chatID (set by ExecuteWithContext), fall back to SetContext values.
+ channel := ToolChannel(ctx)
+ if channel == "" {
+ channel = t.defaultChannel
+ }
+ chatID := ToolChatID(ctx)
+ if chatID == "" {
+ chatID = t.defaultChatID
+ }
+ if channel == "" || chatID == "" {
+ return ErrorResult("no target channel/chat available")
+ }
+
+ if t.mediaStore == nil {
+ return ErrorResult("media store not configured")
+ }
+
+ resolved, err := validatePathWithAllowPaths(path, t.workspace, t.restrict, t.allowPaths)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("invalid path: %v", err))
+ }
+
+ info, err := os.Stat(resolved)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("file not found: %v", err))
+ }
+ if info.IsDir() {
+ return ErrorResult("path is a directory, expected an image file")
+ }
+ if info.Size() > int64(t.maxFileSize) {
+ return ErrorResult(fmt.Sprintf(
+ "file too large: %d bytes (max %d bytes)", info.Size(), t.maxFileSize,
+ ))
+ }
+
+ // Detect MIME type — reuse the helper already in send_file.go
+ mediaType := detectMediaType(resolved)
+ if !strings.HasPrefix(mediaType, "image/") {
+ return ErrorResult(fmt.Sprintf(
+ "file does not appear to be an image (detected type: %s)", mediaType,
+ ))
+ }
+
+ filename := filepath.Base(resolved)
+ scope := fmt.Sprintf("tool:load_image:%s:%s", channel, chatID)
+
+ ref, err := t.mediaStore.Store(resolved, media.MediaMeta{
+ Filename: filename,
+ ContentType: mediaType,
+ Source: "tool:load_image",
+ CleanupPolicy: media.CleanupPolicyForgetOnly,
+ }, scope)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to register image in media store: %v", err))
+ }
+
+ // Build the tool result text. The media:// ref in Media will be picked
+ // up by resolveMediaRefs in agent_media.go and base64-encoded for tool
+ // result messages (role="tool"), so the LLM can see the image content.
+ msg := fmt.Sprintf("Image loaded: %s\n[image: photo]", filename)
+
+ return &ToolResult{
+ ForLLM: msg,
+ ForUser: fmt.Sprintf("Loaded image: %s", filename),
+ // Media refs inside ForLLM are resolved by resolveMediaRefs in the
+ // agent loop before the next LLM call. Do NOT use MediaResult here —
+ // that would send the file to the user channel instead.
+ Media: []string{ref},
+ }
+}
diff --git a/pkg/tools/fs/load_image_test.go b/pkg/tools/fs/load_image_test.go
new file mode 100644
index 000000000..d33db73be
--- /dev/null
+++ b/pkg/tools/fs/load_image_test.go
@@ -0,0 +1,152 @@
+package fstools
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/media"
+)
+
+func TestLoadImage_PathRequired(t *testing.T) {
+ tool := NewLoadImageTool("/tmp", false, 0, nil)
+ ctx := WithToolContext(context.Background(), "test", "chat1")
+ result := tool.Execute(ctx, map[string]any{})
+ if !result.IsError {
+ t.Fatal("expected error for missing path")
+ }
+}
+
+func TestLoadImage_NilMediaStore(t *testing.T) {
+ tool := NewLoadImageTool("/tmp", false, 0, nil)
+ ctx := WithToolContext(context.Background(), "test", "chat1")
+ result := tool.Execute(ctx, map[string]any{"path": "test.png"})
+ if !result.IsError || result.ForLLM != "media store not configured" {
+ t.Fatalf("expected media store error, got: %s", result.ForLLM)
+ }
+}
+
+func TestLoadImage_NoChannelContext(t *testing.T) {
+ store := media.NewFileMediaStore()
+ tool := NewLoadImageTool("/tmp", false, 0, store)
+ // No WithToolContext — should fail
+ result := tool.Execute(context.Background(), map[string]any{"path": "test.png"})
+ if !result.IsError || result.ForLLM != "no target channel/chat available" {
+ t.Fatalf("expected channel error, got: %s", result.ForLLM)
+ }
+}
+
+func TestLoadImage_NonImageFile(t *testing.T) {
+ dir := t.TempDir()
+ txtFile := filepath.Join(dir, "readme.txt")
+ os.WriteFile(txtFile, []byte("hello"), 0o644)
+
+ store := media.NewFileMediaStore()
+ tool := NewLoadImageTool(dir, false, 0, store)
+ ctx := WithToolContext(context.Background(), "test", "chat1")
+ result := tool.Execute(ctx, map[string]any{"path": txtFile})
+ if !result.IsError {
+ t.Fatal("expected error for non-image file")
+ }
+}
+
+func TestLoadImage_DefaultMaxSize(t *testing.T) {
+ tool := NewLoadImageTool("/tmp", false, 0, nil)
+ if tool.maxFileSize != config.DefaultMaxMediaSize {
+ t.Errorf("expected default max size %d, got %d", config.DefaultMaxMediaSize, tool.maxFileSize)
+ }
+}
+
+func TestLoadImage_FileTooLarge(t *testing.T) {
+ dir := t.TempDir()
+ bigFile := filepath.Join(dir, "big.png")
+ // Create a file with PNG header but exceeding max size
+ data := make([]byte, 1024)
+ copy(data, []byte{0x89, 0x50, 0x4E, 0x47}) // PNG magic bytes
+ os.WriteFile(bigFile, data, 0o644)
+
+ store := media.NewFileMediaStore()
+ tool := NewLoadImageTool(dir, false, 512, store) // maxSize = 512
+ ctx := WithToolContext(context.Background(), "test", "chat1")
+ result := tool.Execute(ctx, map[string]any{"path": bigFile})
+ if !result.IsError {
+ t.Fatal("expected error for oversized file")
+ }
+}
+
+func TestLoadImage_SuccessPath(t *testing.T) {
+ dir := t.TempDir()
+
+ // Create a minimal valid PNG file (8-byte signature + minimal IHDR + IEND).
+ // The PNG spec requires the 8-byte magic header: 0x89 P N G \r \n 0x1a \n
+ pngSignature := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
+ // IHDR chunk: length(13) + "IHDR" + 1x1 px, 8-bit RGB, no interlace + CRC
+ ihdr := []byte{
+ 0x00, 0x00, 0x00, 0x0D, // chunk length = 13
+ 0x49, 0x48, 0x44, 0x52, // "IHDR"
+ 0x00, 0x00, 0x00, 0x01, // width = 1
+ 0x00, 0x00, 0x00, 0x01, // height = 1
+ 0x08, // bit depth = 8
+ 0x02, // color type = RGB
+ 0x00, 0x00, 0x00, // compression, filter, interlace
+ 0x90, 0x77, 0x53, 0xDE, // CRC (valid for this IHDR)
+ }
+ // IEND chunk
+ iend := []byte{
+ 0x00, 0x00, 0x00, 0x00, // chunk length = 0
+ 0x49, 0x45, 0x4E, 0x44, // "IEND"
+ 0xAE, 0x42, 0x60, 0x82, // CRC
+ }
+
+ pngData := make([]byte, 0, len(pngSignature)+len(ihdr)+len(iend))
+ pngData = append(pngData, pngSignature...)
+ pngData = append(pngData, ihdr...)
+ pngData = append(pngData, iend...)
+
+ imgPath := filepath.Join(dir, "test_image.png")
+ if err := os.WriteFile(imgPath, pngData, 0o644); err != nil {
+ t.Fatalf("failed to create test PNG: %v", err)
+ }
+
+ store := media.NewFileMediaStore()
+ tool := NewLoadImageTool(dir, false, 0, store)
+ ctx := WithToolContext(context.Background(), "test", "chat1")
+
+ result := tool.Execute(ctx, map[string]any{"path": imgPath})
+
+ // 1. Must not be an error
+ if result.IsError {
+ t.Fatalf("expected success, got error: %s", result.ForLLM)
+ }
+
+ // 2. Media must contain exactly one media:// ref
+ if len(result.Media) != 1 {
+ t.Fatalf("expected 1 media ref, got %d", len(result.Media))
+ }
+ if !strings.HasPrefix(result.Media[0], "media://") {
+ t.Errorf("expected media ref to start with 'media://', got: %s", result.Media[0])
+ }
+
+ // 3. ForLLM must contain the [image: marker
+ if !strings.Contains(result.ForLLM, "[image:") {
+ t.Errorf("expected ForLLM to contain '[image:' marker, got: %s", result.ForLLM)
+ }
+
+ // 4. ForLLM should contain the generic [image: photo] placeholder
+ // (resolveMediaRefs will replace it with the actual path later)
+ if !strings.Contains(result.ForLLM, "[image: photo]") {
+ t.Errorf("expected ForLLM to contain '[image: photo]' placeholder, got: %s", result.ForLLM)
+ }
+
+ // 5. Verify the ref is resolvable in the store
+ resolved, err := store.Resolve(result.Media[0])
+ if err != nil {
+ t.Fatalf("media ref not resolvable: %v", err)
+ }
+ if resolved != imgPath {
+ t.Errorf("expected resolved path %q, got %q", imgPath, resolved)
+ }
+}
diff --git a/pkg/tools/send_file.go b/pkg/tools/fs/send_file.go
similarity index 99%
rename from pkg/tools/send_file.go
rename to pkg/tools/fs/send_file.go
index 44198381e..e4f90bf61 100644
--- a/pkg/tools/send_file.go
+++ b/pkg/tools/fs/send_file.go
@@ -1,4 +1,4 @@
-package tools
+package fstools
import (
"context"
diff --git a/pkg/tools/send_file_test.go b/pkg/tools/fs/send_file_test.go
similarity index 99%
rename from pkg/tools/send_file_test.go
rename to pkg/tools/fs/send_file_test.go
index f36baf7d0..771393b75 100644
--- a/pkg/tools/send_file_test.go
+++ b/pkg/tools/fs/send_file_test.go
@@ -1,4 +1,4 @@
-package tools
+package fstools
import (
"context"
diff --git a/pkg/tools/fs/shared.go b/pkg/tools/fs/shared.go
new file mode 100644
index 000000000..6d46e692b
--- /dev/null
+++ b/pkg/tools/fs/shared.go
@@ -0,0 +1,37 @@
+package fstools
+
+import (
+ "context"
+
+ toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
+)
+
+type ToolResult = toolshared.ToolResult
+
+func WithToolContext(ctx context.Context, channel, chatID string) context.Context {
+ return toolshared.WithToolContext(ctx, channel, chatID)
+}
+
+func ToolChannel(ctx context.Context) string {
+ return toolshared.ToolChannel(ctx)
+}
+
+func ToolChatID(ctx context.Context) string {
+ return toolshared.ToolChatID(ctx)
+}
+
+func ErrorResult(message string) *ToolResult {
+ return toolshared.ErrorResult(message)
+}
+
+func NewToolResult(forLLM string) *ToolResult {
+ return toolshared.NewToolResult(forLLM)
+}
+
+func SilentResult(forLLM string) *ToolResult {
+ return toolshared.SilentResult(forLLM)
+}
+
+func MediaResult(forLLM string, mediaRefs []string) *ToolResult {
+ return toolshared.MediaResult(forLLM, mediaRefs)
+}
diff --git a/pkg/tools/fs_facade.go b/pkg/tools/fs_facade.go
new file mode 100644
index 000000000..5ed68f04c
--- /dev/null
+++ b/pkg/tools/fs_facade.go
@@ -0,0 +1,100 @@
+package tools
+
+import (
+ "regexp"
+
+ "github.com/sipeed/picoclaw/pkg/media"
+ fstools "github.com/sipeed/picoclaw/pkg/tools/fs"
+)
+
+type (
+ ReadFileTool = fstools.ReadFileTool
+ ReadFileLinesTool = fstools.ReadFileLinesTool
+ WriteFileTool = fstools.WriteFileTool
+ ListDirTool = fstools.ListDirTool
+ EditFileTool = fstools.EditFileTool
+ AppendFileTool = fstools.AppendFileTool
+ LoadImageTool = fstools.LoadImageTool
+ SendFileTool = fstools.SendFileTool
+)
+
+const MaxReadFileSize = fstools.MaxReadFileSize
+
+func NewReadFileTool(
+ workspace string,
+ restrict bool,
+ maxReadFileSize int,
+ allowPaths ...[]*regexp.Regexp,
+) *ReadFileTool {
+ return fstools.NewReadFileTool(workspace, restrict, maxReadFileSize, allowPaths...)
+}
+
+func NewReadFileBytesTool(
+ workspace string,
+ restrict bool,
+ maxReadFileSize int,
+ allowPaths ...[]*regexp.Regexp,
+) *ReadFileTool {
+ return fstools.NewReadFileBytesTool(workspace, restrict, maxReadFileSize, allowPaths...)
+}
+
+func NewReadFileLinesTool(
+ workspace string,
+ restrict bool,
+ maxReadFileSize int,
+ allowPaths ...[]*regexp.Regexp,
+) *ReadFileLinesTool {
+ return fstools.NewReadFileLinesTool(workspace, restrict, maxReadFileSize, allowPaths...)
+}
+
+func NewWriteFileTool(
+ workspace string,
+ restrict bool,
+ allowPaths ...[]*regexp.Regexp,
+) *WriteFileTool {
+ return fstools.NewWriteFileTool(workspace, restrict, allowPaths...)
+}
+
+func NewListDirTool(
+ workspace string,
+ restrict bool,
+ allowPaths ...[]*regexp.Regexp,
+) *ListDirTool {
+ return fstools.NewListDirTool(workspace, restrict, allowPaths...)
+}
+
+func NewEditFileTool(
+ workspace string,
+ restrict bool,
+ allowPaths ...[]*regexp.Regexp,
+) *EditFileTool {
+ return fstools.NewEditFileTool(workspace, restrict, allowPaths...)
+}
+
+func NewAppendFileTool(
+ workspace string,
+ restrict bool,
+ allowPaths ...[]*regexp.Regexp,
+) *AppendFileTool {
+ return fstools.NewAppendFileTool(workspace, restrict, allowPaths...)
+}
+
+func NewLoadImageTool(
+ workspace string,
+ restrict bool,
+ maxFileSize int,
+ store media.MediaStore,
+ allowPaths ...[]*regexp.Regexp,
+) *LoadImageTool {
+ return fstools.NewLoadImageTool(workspace, restrict, maxFileSize, store, allowPaths...)
+}
+
+func NewSendFileTool(
+ workspace string,
+ restrict bool,
+ maxFileSize int,
+ store media.MediaStore,
+ allowPaths ...[]*regexp.Regexp,
+) *SendFileTool {
+ return fstools.NewSendFileTool(workspace, restrict, maxFileSize, store, allowPaths...)
+}
diff --git a/pkg/tools/fs_registry_compat_test.go b/pkg/tools/fs_registry_compat_test.go
new file mode 100644
index 000000000..51e080217
--- /dev/null
+++ b/pkg/tools/fs_registry_compat_test.go
@@ -0,0 +1,46 @@
+package tools
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestReadFileLinesTool_RegistryValidationSupportsMaxLinesAndRejectsLimit(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "registry_lines.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ reg := NewToolRegistry()
+ reg.Register(NewReadFileLinesTool(tmpDir, false, MaxReadFileSize))
+
+ result := reg.Execute(context.Background(), "read_file", map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ "max_lines": 1,
+ })
+ if result.IsError {
+ t.Fatalf("expected max_lines to pass registry validation, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "1|line 1\n") {
+ t.Fatalf("expected first line via max_lines, got: %s", result.ForLLM)
+ }
+
+ result = reg.Execute(context.Background(), "read_file", map[string]any{
+ "path": testFile,
+ "start_line": 2,
+ "limit": 1,
+ })
+ if !result.IsError {
+ t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "unexpected property \"limit\"") {
+ t.Fatalf("expected registry validation error for limit, got: %s", result.ForLLM)
+ }
+}
diff --git a/pkg/tools/i2c.go b/pkg/tools/hardware/i2c.go
similarity index 97%
rename from pkg/tools/i2c.go
rename to pkg/tools/hardware/i2c.go
index 779b1d5a7..62e9557ee 100644
--- a/pkg/tools/i2c.go
+++ b/pkg/tools/hardware/i2c.go
@@ -1,4 +1,4 @@
-package tools
+package hardwaretools
import (
"context"
@@ -120,16 +120,12 @@ func (t *I2CTool) detect() *ToolResult {
// Helper functions for I2C operations (used by platform-specific implementations)
// isValidBusID checks that a bus identifier is a simple number (prevents path injection)
-//
-//nolint:unused // Used by i2c_linux.go
func isValidBusID(id string) bool {
matched, _ := regexp.MatchString(`^\d+$`, id)
return matched
}
// parseI2CAddress extracts and validates an I2C address from args
-//
-//nolint:unused // Used by i2c_linux.go
func parseI2CAddress(args map[string]any) (int, *ToolResult) {
addrFloat, ok := args["address"].(float64)
if !ok {
@@ -143,8 +139,6 @@ func parseI2CAddress(args map[string]any) (int, *ToolResult) {
}
// parseI2CBus extracts and validates an I2C bus from args
-//
-//nolint:unused // Used by i2c_linux.go
func parseI2CBus(args map[string]any) (string, *ToolResult) {
bus, ok := args["bus"].(string)
if !ok || bus == "" {
@@ -155,3 +149,9 @@ func parseI2CBus(args map[string]any) (string, *ToolResult) {
}
return bus, nil
}
+
+var (
+ _ = isValidBusID
+ _ = parseI2CAddress
+ _ = parseI2CBus
+)
diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/hardware/i2c_linux.go
similarity index 99%
rename from pkg/tools/i2c_linux.go
rename to pkg/tools/hardware/i2c_linux.go
index 4eaaf8f09..771d11d90 100644
--- a/pkg/tools/i2c_linux.go
+++ b/pkg/tools/hardware/i2c_linux.go
@@ -1,4 +1,4 @@
-package tools
+package hardwaretools
import (
"encoding/json"
diff --git a/pkg/tools/i2c_other.go b/pkg/tools/hardware/i2c_other.go
similarity index 95%
rename from pkg/tools/i2c_other.go
rename to pkg/tools/hardware/i2c_other.go
index 7becf8339..4a0a130e0 100644
--- a/pkg/tools/i2c_other.go
+++ b/pkg/tools/hardware/i2c_other.go
@@ -1,6 +1,6 @@
//go:build !linux
-package tools
+package hardwaretools
// scan is a stub for non-Linux platforms.
func (t *I2CTool) scan(args map[string]any) *ToolResult {
diff --git a/pkg/tools/hardware/serial.go b/pkg/tools/hardware/serial.go
new file mode 100644
index 000000000..7e197a909
--- /dev/null
+++ b/pkg/tools/hardware/serial.go
@@ -0,0 +1,453 @@
+package hardwaretools
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math"
+ "regexp"
+ "runtime"
+ "strings"
+ "time"
+ "unicode/utf8"
+)
+
+const (
+ defaultSerialBaud = 115200
+ defaultSerialDataBits = 8
+ defaultSerialStopBits = 1
+ defaultSerialTimeoutMS = 1000
+ maxSerialPayloadBytes = 4096
+ maxSerialReadBytes = 4096
+ serialPollInterval = 100 * time.Millisecond
+)
+
+var (
+ unixSerialPortPattern = regexp.MustCompile(
+ `^(?:/dev/)?(?:ttyS\d+|ttyUSB\d+|ttyACM\d+|ttyAMA\d+|rfcomm\d+|tty\.[A-Za-z0-9._-]+|cu\.[A-Za-z0-9._-]+)$`,
+ )
+ windowsSerialPortPattern = regexp.MustCompile(`^(?:\\\\\.\\)?COM[1-9]\d*$`)
+ unixSerialBaudRates = map[int]struct{}{
+ 50: {}, 75: {}, 110: {}, 134: {}, 150: {}, 200: {}, 300: {}, 600: {}, 1200: {}, 1800: {},
+ 2400: {}, 4800: {}, 9600: {}, 19200: {}, 38400: {}, 57600: {}, 115200: {}, 230400: {},
+ }
+)
+
+type SerialTool struct{}
+
+type serialPortInfo struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+}
+
+type serialConfig struct {
+ Port string
+ Baud int
+ DataBits int
+ Parity string
+ StopBits int
+}
+
+func NewSerialTool() *SerialTool {
+ return &SerialTool{}
+}
+
+func (t *SerialTool) Name() string {
+ return "serial"
+}
+
+func (t *SerialTool) Description() string {
+ return "Interact with host serial ports. Actions: list (enumerate ports), read (receive bytes), write (send bytes with explicit confirmation). Supports Linux, macOS, and Windows."
+}
+
+func (t *SerialTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "action": map[string]any{
+ "type": "string",
+ "enum": []string{"list", "read", "write"},
+ "description": "Action to perform: list available serial ports, read bytes from a port, or write bytes to a port.",
+ },
+ "port": map[string]any{
+ "type": "string",
+ "description": "Serial port path or name, for example /dev/ttyUSB0, /dev/cu.usbserial-0001, or COM3. Required for read/write.",
+ },
+ "baud": map[string]any{
+ "type": "integer",
+ "description": "Baud rate. Default: 115200. Linux/macOS currently support standard termios rates up to 230400; Windows accepts configured rates up to 4000000.",
+ },
+ "data_bits": map[string]any{
+ "type": "integer",
+ "description": "Data bits. Supported values: 5, 6, 7, 8. Default: 8.",
+ },
+ "parity": map[string]any{
+ "type": "string",
+ "enum": []string{"none", "even", "odd"},
+ "description": "Parity mode. Default: none.",
+ },
+ "stop_bits": map[string]any{
+ "type": "integer",
+ "description": "Stop bits. Supported values: 1, 2. Default: 1.",
+ },
+ "timeout_ms": map[string]any{
+ "type": "integer",
+ "description": "Read/write timeout in milliseconds. Default: 1000.",
+ },
+ "length": map[string]any{
+ "type": "integer",
+ "description": "Number of bytes to read. Required for read. Range: 1-4096.",
+ },
+ "data": map[string]any{
+ "type": "array",
+ "items": map[string]any{"type": "integer"},
+ "description": "Bytes to write, each in range 0-255. Required for write unless text is provided.",
+ },
+ "text": map[string]any{
+ "type": "string",
+ "description": "UTF-8 text to write. Required for write if data is omitted.",
+ },
+ "confirm": map[string]any{
+ "type": "boolean",
+ "description": "Must be true for write operations.",
+ },
+ },
+ "required": []string{"action"},
+ }
+}
+
+func (t *SerialTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ action, ok := args["action"].(string)
+ if !ok || strings.TrimSpace(action) == "" {
+ return ErrorResult("action is required")
+ }
+
+ switch action {
+ case "list":
+ return t.list()
+ case "read":
+ return t.read(ctx, args)
+ case "write":
+ return t.write(ctx, args)
+ default:
+ return ErrorResult(fmt.Sprintf("unknown action: %s (valid: list, read, write)", action))
+ }
+}
+
+func (t *SerialTool) list() *ToolResult {
+ ports, err := serialListPorts()
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to list serial ports: %v", err))
+ }
+ if len(ports) == 0 {
+ return SilentResult("No serial ports found on this host.")
+ }
+
+ result, _ := json.MarshalIndent(map[string]any{
+ "ports": ports,
+ "count": len(ports),
+ }, "", " ")
+ return SilentResult(string(result))
+}
+
+func (t *SerialTool) read(ctx context.Context, args map[string]any) *ToolResult {
+ cfg, errResult := parseSerialConfig(args)
+ if errResult != nil {
+ return errResult
+ }
+
+ length := 0
+ if v, ok := args["length"].(float64); ok {
+ length = int(v)
+ }
+ if length < 1 || length > maxSerialReadBytes {
+ return ErrorResult(fmt.Sprintf("length is required for read (1-%d)", maxSerialReadBytes))
+ }
+
+ timeout, errResult := parseSerialTimeout(args)
+ if errResult != nil {
+ return errResult
+ }
+
+ data, err := serialRead(ctx, cfg, length, timeout)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("serial read failed on %s: %v", cfg.Port, err))
+ }
+
+ return SilentResult(formatSerialPayload("read", cfg, data, timeout))
+}
+
+func (t *SerialTool) write(ctx context.Context, args map[string]any) *ToolResult {
+ confirm, _ := args["confirm"].(bool)
+ if !confirm {
+ return ErrorResult(
+ "write operations require confirm: true. Please confirm with the user before sending bytes to a serial device.",
+ )
+ }
+
+ cfg, errResult := parseSerialConfig(args)
+ if errResult != nil {
+ return errResult
+ }
+ timeout, errResult := parseSerialTimeout(args)
+ if errResult != nil {
+ return errResult
+ }
+ payload, errResult := parseSerialWritePayload(args)
+ if errResult != nil {
+ return errResult
+ }
+
+ written, err := serialWrite(ctx, cfg, payload, timeout)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("serial write failed on %s: %v", cfg.Port, err))
+ }
+
+ result, _ := json.MarshalIndent(map[string]any{
+ "action": "write",
+ "port": cfg.Port,
+ "baud": cfg.Baud,
+ "data_bits": cfg.DataBits,
+ "parity": cfg.Parity,
+ "stop_bits": cfg.StopBits,
+ "timeout_ms": timeout.Milliseconds(),
+ "written": written,
+ "payload": serialPayloadSummary(payload),
+ }, "", " ")
+ return SilentResult(string(result))
+}
+
+func parseSerialConfig(args map[string]any) (serialConfig, *ToolResult) {
+ port, ok := args["port"].(string)
+ port = strings.TrimSpace(port)
+ if !ok || port == "" {
+ return serialConfig{}, ErrorResult(
+ "port is required (for example /dev/ttyUSB0, /dev/cu.usbserial-0001, or COM3)",
+ )
+ }
+
+ normalizedPort, err := normalizeSerialPort(port)
+ if err != nil {
+ return serialConfig{}, ErrorResult(err.Error())
+ }
+
+ cfg := serialConfig{
+ Port: normalizedPort,
+ Baud: defaultSerialBaud,
+ DataBits: defaultSerialDataBits,
+ Parity: "none",
+ StopBits: defaultSerialStopBits,
+ }
+
+ if v, ok := args["baud"].(float64); ok {
+ cfg.Baud = int(v)
+ }
+ if err := validateSerialBaud(cfg.Baud); err != nil {
+ return serialConfig{}, ErrorResult(err.Error())
+ }
+
+ if v, ok := args["data_bits"].(float64); ok {
+ cfg.DataBits = int(v)
+ }
+ switch cfg.DataBits {
+ case 5, 6, 7, 8:
+ default:
+ return serialConfig{}, ErrorResult("data_bits must be one of 5, 6, 7, or 8")
+ }
+
+ if v, ok := args["parity"].(string); ok && strings.TrimSpace(v) != "" {
+ cfg.Parity = strings.ToLower(strings.TrimSpace(v))
+ }
+ switch cfg.Parity {
+ case "none", "even", "odd":
+ default:
+ return serialConfig{}, ErrorResult(`parity must be one of "none", "even", or "odd"`)
+ }
+
+ if v, ok := args["stop_bits"].(float64); ok {
+ cfg.StopBits = int(v)
+ }
+ if cfg.StopBits != 1 && cfg.StopBits != 2 {
+ return serialConfig{}, ErrorResult("stop_bits must be 1 or 2")
+ }
+
+ return cfg, nil
+}
+
+func parseSerialTimeout(args map[string]any) (time.Duration, *ToolResult) {
+ timeoutMS := defaultSerialTimeoutMS
+ if v, ok := args["timeout_ms"].(float64); ok {
+ timeoutMS = int(v)
+ }
+ if timeoutMS < 1 || timeoutMS > 60000 {
+ return 0, ErrorResult("timeout_ms must be between 1 and 60000")
+ }
+ return time.Duration(timeoutMS) * time.Millisecond, nil
+}
+
+func parseSerialWritePayload(args map[string]any) ([]byte, *ToolResult) {
+ if text, ok := args["text"].(string); ok && text != "" {
+ if !utf8.ValidString(text) {
+ return nil, ErrorResult("text must be valid UTF-8")
+ }
+ if len(text) > maxSerialPayloadBytes {
+ return nil, ErrorResult(fmt.Sprintf("text payload too large: maximum %d bytes", maxSerialPayloadBytes))
+ }
+ return []byte(text), nil
+ }
+
+ dataRaw, ok := args["data"].([]any)
+ if !ok || len(dataRaw) == 0 {
+ return nil, ErrorResult("write requires either text or data")
+ }
+ if len(dataRaw) > maxSerialPayloadBytes {
+ return nil, ErrorResult(fmt.Sprintf("data too long: maximum %d bytes", maxSerialPayloadBytes))
+ }
+
+ data := make([]byte, len(dataRaw))
+ for i, v := range dataRaw {
+ f, ok := v.(float64)
+ if !ok {
+ return nil, ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i))
+ }
+ if f != math.Trunc(f) {
+ return nil, ErrorResult(fmt.Sprintf("data[%d] is not an integer byte value", i))
+ }
+ b := int(f)
+ if b < 0 || b > 255 {
+ return nil, ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b))
+ }
+ data[i] = byte(b)
+ }
+
+ return data, nil
+}
+
+func formatSerialPayload(action string, cfg serialConfig, data []byte, timeout time.Duration) string {
+ result, _ := json.MarshalIndent(map[string]any{
+ "action": action,
+ "port": cfg.Port,
+ "baud": cfg.Baud,
+ "data_bits": cfg.DataBits,
+ "parity": cfg.Parity,
+ "stop_bits": cfg.StopBits,
+ "timeout_ms": timeout.Milliseconds(),
+ "payload": serialPayloadSummary(data),
+ }, "", " ")
+ return string(result)
+}
+
+func serialPayloadSummary(data []byte) map[string]any {
+ hexValues := make([]string, len(data))
+ intValues := make([]int, len(data))
+ for i, b := range data {
+ hexValues[i] = fmt.Sprintf("0x%02x", b)
+ intValues[i] = int(b)
+ }
+
+ summary := map[string]any{
+ "length": len(data),
+ "bytes": intValues,
+ "hex": hexValues,
+ }
+ if utf8.Valid(data) {
+ summary["text"] = string(data)
+ }
+ return summary
+}
+
+func normalizeSerialPort(port string) (string, error) {
+ switch runtime.GOOS {
+ case "windows":
+ return normalizeWindowsSerialPath(port)
+ case "linux", "darwin":
+ return normalizeUnixSerialPath(port)
+ default:
+ if normalized, err := normalizeUnixSerialPath(port); err == nil {
+ return normalized, nil
+ }
+ return normalizeWindowsSerialPath(port)
+ }
+}
+
+func normalizeUnixSerialPath(port string) (string, error) {
+ trimmed := strings.TrimSpace(port)
+ if !unixSerialPortPattern.MatchString(trimmed) {
+ return "", fmt.Errorf(
+ "invalid serial port: expected a safe Unix device name such as /dev/ttyUSB0 or /dev/cu.usbserial-0001",
+ )
+ }
+ if strings.HasPrefix(trimmed, "/dev/") {
+ return trimmed, nil
+ }
+ return "/dev/" + trimmed, nil
+}
+
+func normalizeWindowsSerialPath(port string) (string, error) {
+ trimmed := strings.ToUpper(strings.TrimSpace(port))
+ if !windowsSerialPortPattern.MatchString(trimmed) {
+ return "", fmt.Errorf("invalid serial port: expected a COM port such as COM3")
+ }
+ if strings.HasPrefix(trimmed, `\\.\`) {
+ return trimmed, nil
+ }
+ return `\\.\` + trimmed, nil
+}
+
+func validateSerialBaud(baud int) error {
+ if baud < 50 || baud > 4000000 {
+ return fmt.Errorf("baud must be between 50 and 4000000")
+ }
+
+ switch runtime.GOOS {
+ case "linux", "darwin":
+ if _, ok := unixSerialBaudRates[baud]; !ok {
+ return fmt.Errorf("unsupported baud rate on this platform: %d (supported up to 230400)", baud)
+ }
+ }
+
+ return nil
+}
+
+func serialContextErr(ctx context.Context) error {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ return nil
+ }
+}
+
+func serialWriteAll(
+ ctx context.Context,
+ data []byte,
+ timeout time.Duration,
+ now func() time.Time,
+ write func([]byte) (int, error),
+) (int, error) {
+ if err := serialContextErr(ctx); err != nil {
+ return 0, err
+ }
+
+ total := 0
+ deadline := now().Add(timeout)
+ for total < len(data) {
+ if err := serialContextErr(ctx); err != nil {
+ return total, err
+ }
+ if deadline.Sub(now()) <= 0 {
+ return total, fmt.Errorf("timeout while writing serial data")
+ }
+
+ n, err := write(data[total:])
+ total += n
+ if err != nil {
+ return total, err
+ }
+ if n == 0 {
+ continue
+ }
+ }
+
+ return total, nil
+}
diff --git a/pkg/tools/hardware/serial_darwin.go b/pkg/tools/hardware/serial_darwin.go
new file mode 100644
index 000000000..bc019029e
--- /dev/null
+++ b/pkg/tools/hardware/serial_darwin.go
@@ -0,0 +1,19 @@
+//go:build darwin
+
+package hardwaretools
+
+import "golang.org/x/sys/unix"
+
+func serialGetTermios(fd int) (*unix.Termios, error) {
+ return unix.IoctlGetTermios(fd, unix.TIOCGETA)
+}
+
+func serialSetSpeed(tio *unix.Termios, speed uint32) error {
+ tio.Ispeed = uint64(speed)
+ tio.Ospeed = uint64(speed)
+ return nil
+}
+
+func serialSetTermios(fd int, tio *unix.Termios) error {
+ return unix.IoctlSetTermios(fd, unix.TIOCSETA, tio)
+}
diff --git a/pkg/tools/hardware/serial_linux.go b/pkg/tools/hardware/serial_linux.go
new file mode 100644
index 000000000..bad3e4cb8
--- /dev/null
+++ b/pkg/tools/hardware/serial_linux.go
@@ -0,0 +1,19 @@
+//go:build linux
+
+package hardwaretools
+
+import "golang.org/x/sys/unix"
+
+func serialGetTermios(fd int) (*unix.Termios, error) {
+ return unix.IoctlGetTermios(fd, unix.TCGETS)
+}
+
+func serialSetSpeed(tio *unix.Termios, speed uint32) error {
+ tio.Ispeed = speed
+ tio.Ospeed = speed
+ return nil
+}
+
+func serialSetTermios(fd int, tio *unix.Termios) error {
+ return unix.IoctlSetTermios(fd, unix.TCSETS, tio)
+}
diff --git a/pkg/tools/hardware/serial_other.go b/pkg/tools/hardware/serial_other.go
new file mode 100644
index 000000000..ec72a2d2a
--- /dev/null
+++ b/pkg/tools/hardware/serial_other.go
@@ -0,0 +1,21 @@
+//go:build !linux && !darwin && !windows
+
+package hardwaretools
+
+import (
+ "context"
+ "fmt"
+ "time"
+)
+
+func serialListPorts() ([]serialPortInfo, error) {
+ return nil, fmt.Errorf("serial is not supported on this platform")
+}
+
+func serialRead(ctx context.Context, cfg serialConfig, length int, timeout time.Duration) ([]byte, error) {
+ return nil, fmt.Errorf("serial is not supported on this platform")
+}
+
+func serialWrite(ctx context.Context, cfg serialConfig, data []byte, timeout time.Duration) (int, error) {
+ return 0, fmt.Errorf("serial is not supported on this platform")
+}
diff --git a/pkg/tools/hardware/serial_other_test.go b/pkg/tools/hardware/serial_other_test.go
new file mode 100644
index 000000000..ef04c4062
--- /dev/null
+++ b/pkg/tools/hardware/serial_other_test.go
@@ -0,0 +1,18 @@
+//go:build !linux && !darwin && !windows
+
+package hardwaretools
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestSerialListPortsUnsupportedPlatform(t *testing.T) {
+ _, err := serialListPorts()
+ if err == nil {
+ t.Fatal("expected unsupported platform error")
+ }
+ if !strings.Contains(err.Error(), "not supported") {
+ t.Fatalf("serialListPorts() error = %v, want unsupported platform message", err)
+ }
+}
diff --git a/pkg/tools/hardware/serial_test.go b/pkg/tools/hardware/serial_test.go
new file mode 100644
index 000000000..6b2e9765d
--- /dev/null
+++ b/pkg/tools/hardware/serial_test.go
@@ -0,0 +1,269 @@
+package hardwaretools
+
+import (
+ "context"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestParseSerialConfig(t *testing.T) {
+ port := "/dev/ttyUSB0"
+ if runtime.GOOS == "windows" {
+ port = "COM3"
+ }
+
+ cfg, errResult := parseSerialConfig(map[string]any{
+ "port": port,
+ "baud": float64(9600),
+ "data_bits": float64(7),
+ "parity": "even",
+ "stop_bits": float64(2),
+ })
+ if errResult != nil {
+ t.Fatalf("parseSerialConfig() unexpected error = %v", errResult.ForLLM)
+ }
+
+ wantPort := "/dev/ttyUSB0"
+ if runtime.GOOS == "windows" {
+ wantPort = `\\.\COM3`
+ }
+ if cfg.Port != wantPort || cfg.Baud != 9600 || cfg.DataBits != 7 || cfg.Parity != "even" || cfg.StopBits != 2 {
+ t.Fatalf("parseSerialConfig() = %#v", cfg)
+ }
+}
+
+func TestParseSerialConfigRejectsInvalidParity(t *testing.T) {
+ port := "/dev/ttyUSB0"
+ if runtime.GOOS == "windows" {
+ port = "COM3"
+ }
+
+ _, errResult := parseSerialConfig(map[string]any{
+ "port": port,
+ "parity": "mark",
+ })
+ if errResult == nil {
+ t.Fatal("expected invalid parity to fail")
+ }
+}
+
+func TestParseSerialConfigRejectsUnsupportedUnixBaud(t *testing.T) {
+ if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
+ t.Skip("Unix baud validation only applies on Unix platforms")
+ }
+
+ _, errResult := parseSerialConfig(map[string]any{
+ "port": "/dev/ttyUSB0",
+ "baud": float64(460800),
+ })
+ if errResult == nil {
+ t.Fatal("expected unsupported Unix baud rate to fail")
+ }
+}
+
+func TestParseSerialWritePayloadRejectsFractionalBytes(t *testing.T) {
+ _, errResult := parseSerialWritePayload(map[string]any{
+ "data": []any{65.9},
+ })
+ if errResult == nil {
+ t.Fatal("expected fractional byte value to fail")
+ }
+}
+
+func TestValidateSerialBaud(t *testing.T) {
+ tests := []struct {
+ name string
+ baud int
+ wantErr bool
+ }{
+ {name: "default-supported", baud: 115200},
+ {name: "max-unix-supported", baud: 230400},
+ {name: "too-low", baud: 49, wantErr: true},
+ {name: "too-high", baud: 4000001, wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateSerialBaud(tt.baud)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("validateSerialBaud(%d) error = %v, wantErr %v", tt.baud, err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestSerialReadCanceledBeforeOpen(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ port := "/dev/ttyUSB0"
+ if runtime.GOOS == "windows" {
+ port = "COM3"
+ }
+
+ _, err := serialRead(
+ ctx,
+ serialConfig{Port: port, Baud: 115200, DataBits: 8, Parity: "none", StopBits: 1},
+ 1,
+ time.Second,
+ )
+ if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) {
+ t.Fatalf("serialRead() error = %v, want context canceled", err)
+ }
+}
+
+func TestSerialWriteCanceledBeforeOpen(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ port := "/dev/ttyUSB0"
+ if runtime.GOOS == "windows" {
+ port = "COM3"
+ }
+
+ _, err := serialWrite(
+ ctx,
+ serialConfig{Port: port, Baud: 115200, DataBits: 8, Parity: "none", StopBits: 1},
+ []byte("AT"),
+ time.Second,
+ )
+ if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) {
+ t.Fatalf("serialWrite() error = %v, want context canceled", err)
+ }
+}
+
+func TestParseSerialConfigRejectsUnsafePortPaths(t *testing.T) {
+ tests := []string{
+ "../../../etc/passwd",
+ "/etc/passwd",
+ `C:\temp\device.txt`,
+ `\\.\C:\temp\device.txt`,
+ }
+
+ for _, port := range tests {
+ t.Run(strings.ReplaceAll(port, "/", "_"), func(t *testing.T) {
+ _, errResult := parseSerialConfig(map[string]any{
+ "port": port,
+ })
+ if errResult == nil {
+ t.Fatalf("expected unsafe port %q to be rejected", port)
+ }
+ })
+ }
+}
+
+func TestNormalizeUnixSerialPath(t *testing.T) {
+ tests := []struct {
+ port string
+ want string
+ }{
+ {port: "ttyUSB0", want: "/dev/ttyUSB0"},
+ {port: "/dev/ttyACM0", want: "/dev/ttyACM0"},
+ {port: "/dev/cu.usbserial-0001", want: "/dev/cu.usbserial-0001"},
+ }
+
+ for _, tt := range tests {
+ got, err := normalizeUnixSerialPath(tt.port)
+ if err != nil {
+ t.Fatalf("normalizeUnixSerialPath(%q) unexpected error = %v", tt.port, err)
+ }
+ if got != tt.want {
+ t.Fatalf("normalizeUnixSerialPath(%q) = %q, want %q", tt.port, got, tt.want)
+ }
+ }
+}
+
+func TestNormalizeUnixSerialPathRejectsInvalidNames(t *testing.T) {
+ tests := []string{
+ "",
+ "ttyUSB0/../../passwd",
+ "/dev/../../etc/passwd",
+ "/tmp/ttyUSB0",
+ "ttyUSB",
+ "COM3",
+ }
+
+ for _, port := range tests {
+ t.Run(strings.ReplaceAll(port, "/", "_"), func(t *testing.T) {
+ if _, err := normalizeUnixSerialPath(port); err == nil {
+ t.Fatalf("expected %q to be rejected", port)
+ }
+ })
+ }
+}
+
+func TestNormalizeWindowsSerialPath(t *testing.T) {
+ tests := []struct {
+ port string
+ want string
+ }{
+ {port: "COM3", want: `\\.\COM3`},
+ {port: "com12", want: `\\.\COM12`},
+ {port: `\\.\COM7`, want: `\\.\COM7`},
+ }
+
+ for _, tt := range tests {
+ got, err := normalizeWindowsSerialPath(tt.port)
+ if err != nil {
+ t.Fatalf("normalizeWindowsSerialPath(%q) unexpected error = %v", tt.port, err)
+ }
+ if got != tt.want {
+ t.Fatalf("normalizeWindowsSerialPath(%q) = %q, want %q", tt.port, got, tt.want)
+ }
+ }
+}
+
+func TestNormalizeWindowsSerialPathRejectsInvalidNames(t *testing.T) {
+ tests := []string{
+ "",
+ "COM0",
+ "COM",
+ "/dev/ttyUSB0",
+ `C:\temp\device.txt`,
+ `\\.\C:\temp\device.txt`,
+ `\\server\share\COM3`,
+ }
+
+ for _, port := range tests {
+ t.Run(strings.ReplaceAll(strings.ReplaceAll(port, `\`, "_"), "/", "_"), func(t *testing.T) {
+ if _, err := normalizeWindowsSerialPath(port); err == nil {
+ t.Fatalf("expected %q to be rejected", port)
+ }
+ })
+ }
+}
+
+func TestParseSerialTimeout(t *testing.T) {
+ timeout, errResult := parseSerialTimeout(map[string]any{
+ "timeout_ms": float64(2500),
+ })
+ if errResult != nil {
+ t.Fatalf("parseSerialTimeout() unexpected error = %v", errResult.ForLLM)
+ }
+ if timeout != 2500*time.Millisecond {
+ t.Fatalf("timeout = %v, want 2500ms", timeout)
+ }
+}
+
+func TestParseSerialWritePayloadSupportsText(t *testing.T) {
+ data, errResult := parseSerialWritePayload(map[string]any{
+ "text": "AT\r\n",
+ })
+ if errResult != nil {
+ t.Fatalf("parseSerialWritePayload() unexpected error = %v", errResult.ForLLM)
+ }
+ if string(data) != "AT\r\n" {
+ t.Fatalf("payload = %q, want %q", string(data), "AT\r\n")
+ }
+}
+
+func TestParseSerialWritePayloadRejectsOutOfRangeByte(t *testing.T) {
+ _, errResult := parseSerialWritePayload(map[string]any{
+ "data": []any{float64(256)},
+ })
+ if errResult == nil {
+ t.Fatal("expected payload validation failure")
+ }
+}
diff --git a/pkg/tools/hardware/serial_unix.go b/pkg/tools/hardware/serial_unix.go
new file mode 100644
index 000000000..548b8573b
--- /dev/null
+++ b/pkg/tools/hardware/serial_unix.go
@@ -0,0 +1,286 @@
+//go:build linux || darwin
+
+package hardwaretools
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+var (
+ unixSerialNow = time.Now
+ unixSerialOpenPort = openAndConfigureSerialPort
+ unixSerialClosePort = unix.Close
+ unixSerialPollRead = pollRead
+ unixSerialPollWrite = pollWrite
+)
+
+func serialListPorts() ([]serialPortInfo, error) {
+ patterns := []string{
+ "/dev/ttyS*",
+ "/dev/ttyUSB*",
+ "/dev/ttyACM*",
+ "/dev/ttyAMA*",
+ "/dev/rfcomm*",
+ "/dev/tty.*",
+ "/dev/cu.*",
+ }
+
+ seen := make(map[string]struct{})
+ ports := make([]serialPortInfo, 0)
+ for _, pattern := range patterns {
+ matches, err := filepath.Glob(pattern)
+ if err != nil {
+ return nil, err
+ }
+ for _, match := range matches {
+ if _, ok := seen[match]; ok {
+ continue
+ }
+ info, err := os.Stat(match)
+ if err != nil || info.IsDir() {
+ continue
+ }
+ seen[match] = struct{}{}
+ ports = append(ports, serialPortInfo{
+ Name: filepath.Base(match),
+ Path: match,
+ })
+ }
+ }
+
+ sort.Slice(ports, func(i, j int) bool {
+ return ports[i].Path < ports[j].Path
+ })
+ return ports, nil
+}
+
+func serialRead(ctx context.Context, cfg serialConfig, length int, timeout time.Duration) ([]byte, error) {
+ if err := serialContextErr(ctx); err != nil {
+ return nil, err
+ }
+
+ fd, err := unixSerialOpenPort(cfg)
+ if err != nil {
+ return nil, err
+ }
+ defer unixSerialClosePort(fd)
+
+ buf := make([]byte, length)
+ total := 0
+ deadline := unixSerialNow().Add(timeout)
+
+ for total < length {
+ if err := serialContextErr(ctx); err != nil {
+ return nil, err
+ }
+
+ remaining := deadline.Sub(unixSerialNow())
+ if remaining <= 0 {
+ break
+ }
+
+ n, err := unixSerialPollRead(fd, buf[total:], minSerialPollTimeout(remaining))
+ if err != nil {
+ return nil, err
+ }
+ if n == 0 {
+ continue
+ }
+ total += n
+ }
+
+ return buf[:total], nil
+}
+
+func serialWrite(ctx context.Context, cfg serialConfig, data []byte, timeout time.Duration) (int, error) {
+ if err := serialContextErr(ctx); err != nil {
+ return 0, err
+ }
+
+ fd, err := unixSerialOpenPort(cfg)
+ if err != nil {
+ return 0, err
+ }
+ defer unixSerialClosePort(fd)
+
+ total := 0
+ deadline := unixSerialNow().Add(timeout)
+ for total < len(data) {
+ if err := serialContextErr(ctx); err != nil {
+ return total, err
+ }
+
+ remaining := deadline.Sub(unixSerialNow())
+ if remaining <= 0 {
+ return total, fmt.Errorf("timeout while writing serial data")
+ }
+
+ n, err := unixSerialPollWrite(fd, data[total:], minSerialPollTimeout(remaining))
+ if err != nil {
+ return total, err
+ }
+ if n == 0 {
+ continue
+ }
+ total += n
+ }
+
+ return total, nil
+}
+
+func openAndConfigureSerialPort(cfg serialConfig) (int, error) {
+ fd, err := unix.Open(cfg.Port, unix.O_RDWR|unix.O_NOCTTY|unix.O_NONBLOCK, 0)
+ if err != nil {
+ return -1, err
+ }
+
+ if err := unix.SetNonblock(fd, false); err != nil {
+ unix.Close(fd)
+ return -1, err
+ }
+
+ if err := configureUnixSerialPort(fd, cfg); err != nil {
+ unix.Close(fd)
+ return -1, err
+ }
+
+ return fd, nil
+}
+
+func configureUnixSerialPort(fd int, cfg serialConfig) error {
+ tio, err := serialGetTermios(fd)
+ if err != nil {
+ return err
+ }
+
+ tio.Iflag = 0
+ tio.Oflag = 0
+ tio.Lflag = 0
+ tio.Cflag = unix.CREAD | unix.CLOCAL
+ tio.Cc[unix.VMIN] = 0
+ tio.Cc[unix.VTIME] = 0
+
+ switch cfg.DataBits {
+ case 5:
+ tio.Cflag |= unix.CS5
+ case 6:
+ tio.Cflag |= unix.CS6
+ case 7:
+ tio.Cflag |= unix.CS7
+ default:
+ tio.Cflag |= unix.CS8
+ }
+
+ switch cfg.Parity {
+ case "even":
+ tio.Cflag |= unix.PARENB
+ case "odd":
+ tio.Cflag |= unix.PARENB | unix.PARODD
+ }
+
+ if cfg.StopBits == 2 {
+ tio.Cflag |= unix.CSTOPB
+ }
+
+ speed, err := serialBaudToUnix(cfg.Baud)
+ if err != nil {
+ return err
+ }
+ if err := serialSetSpeed(tio, speed); err != nil {
+ return err
+ }
+
+ return serialSetTermios(fd, tio)
+}
+
+func serialBaudToUnix(baud int) (uint32, error) {
+ switch baud {
+ case 50:
+ return unix.B50, nil
+ case 75:
+ return unix.B75, nil
+ case 110:
+ return unix.B110, nil
+ case 134:
+ return unix.B134, nil
+ case 150:
+ return unix.B150, nil
+ case 200:
+ return unix.B200, nil
+ case 300:
+ return unix.B300, nil
+ case 600:
+ return unix.B600, nil
+ case 1200:
+ return unix.B1200, nil
+ case 1800:
+ return unix.B1800, nil
+ case 2400:
+ return unix.B2400, nil
+ case 4800:
+ return unix.B4800, nil
+ case 9600:
+ return unix.B9600, nil
+ case 19200:
+ return unix.B19200, nil
+ case 38400:
+ return unix.B38400, nil
+ case 57600:
+ return unix.B57600, nil
+ case 115200:
+ return unix.B115200, nil
+ case 230400:
+ return unix.B230400, nil
+ default:
+ return 0, fmt.Errorf("unsupported baud rate on this platform: %d", baud)
+ }
+}
+
+func pollRead(fd int, dst []byte, timeout time.Duration) (int, error) {
+ pfd := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
+ n, err := unix.Poll(pfd, durationToPollTimeout(timeout))
+ if err != nil {
+ return 0, err
+ }
+ if n == 0 {
+ return 0, nil
+ }
+ return unix.Read(fd, dst)
+}
+
+func pollWrite(fd int, src []byte, timeout time.Duration) (int, error) {
+ pfd := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLOUT}}
+ n, err := unix.Poll(pfd, durationToPollTimeout(timeout))
+ if err != nil {
+ return 0, err
+ }
+ if n == 0 {
+ return 0, nil
+ }
+ return unix.Write(fd, src)
+}
+
+func durationToPollTimeout(timeout time.Duration) int {
+ if timeout <= 0 {
+ return 0
+ }
+ ms := int(timeout / time.Millisecond)
+ if ms == 0 {
+ return 1
+ }
+ return ms
+}
+
+func minSerialPollTimeout(timeout time.Duration) time.Duration {
+ if timeout > serialPollInterval {
+ return serialPollInterval
+ }
+ return timeout
+}
diff --git a/pkg/tools/hardware/serial_unix_test.go b/pkg/tools/hardware/serial_unix_test.go
new file mode 100644
index 000000000..fac2efe7f
--- /dev/null
+++ b/pkg/tools/hardware/serial_unix_test.go
@@ -0,0 +1,140 @@
+//go:build linux || darwin
+
+package hardwaretools
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+func stubUnixSerialIO(t *testing.T, now *time.Time) {
+ t.Helper()
+
+ prevNow := unixSerialNow
+ prevOpen := unixSerialOpenPort
+ prevClose := unixSerialClosePort
+ prevPollRead := unixSerialPollRead
+ prevPollWrite := unixSerialPollWrite
+
+ unixSerialNow = func() time.Time {
+ return *now
+ }
+ unixSerialOpenPort = func(cfg serialConfig) (int, error) {
+ return 42, nil
+ }
+ unixSerialClosePort = func(fd int) error {
+ return nil
+ }
+ unixSerialPollRead = prevPollRead
+ unixSerialPollWrite = prevPollWrite
+
+ t.Cleanup(func() {
+ unixSerialNow = prevNow
+ unixSerialOpenPort = prevOpen
+ unixSerialClosePort = prevClose
+ unixSerialPollRead = prevPollRead
+ unixSerialPollWrite = prevPollWrite
+ })
+}
+
+func TestSerialReadWaitsPastEmptyPollsUntilDeadline(t *testing.T) {
+ now := time.Unix(0, 0)
+ stubUnixSerialIO(t, &now)
+
+ pollCalls := 0
+ unixSerialPollRead = func(fd int, dst []byte, timeout time.Duration) (int, error) {
+ pollCalls++
+ if timeout > serialPollInterval {
+ t.Fatalf("poll timeout = %v, want <= %v", timeout, serialPollInterval)
+ }
+ now = now.Add(timeout)
+ if pollCalls < 4 {
+ return 0, nil
+ }
+ return copy(dst, []byte("OK")), nil
+ }
+
+ got, err := serialRead(context.Background(), serialConfig{}, 2, 500*time.Millisecond)
+ if err != nil {
+ t.Fatalf("serialRead() error = %v", err)
+ }
+ if string(got) != "OK" {
+ t.Fatalf("serialRead() = %q, want %q", got, "OK")
+ }
+ if pollCalls != 4 {
+ t.Fatalf("poll calls = %d, want 4", pollCalls)
+ }
+}
+
+func TestSerialReadReturnsPromptlyOnContextCancelBetweenPolls(t *testing.T) {
+ now := time.Unix(0, 0)
+ stubUnixSerialIO(t, &now)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ pollCalls := 0
+ unixSerialPollRead = func(fd int, dst []byte, timeout time.Duration) (int, error) {
+ pollCalls++
+ now = now.Add(timeout)
+ cancel()
+ return 0, nil
+ }
+
+ _, err := serialRead(ctx, serialConfig{}, 1, time.Second)
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("serialRead() error = %v, want context canceled", err)
+ }
+ if pollCalls != 1 {
+ t.Fatalf("poll calls = %d, want 1", pollCalls)
+ }
+}
+
+func TestSerialWriteWaitsPastEmptyPollsUntilReady(t *testing.T) {
+ now := time.Unix(0, 0)
+ stubUnixSerialIO(t, &now)
+
+ pollCalls := 0
+ unixSerialPollWrite = func(fd int, src []byte, timeout time.Duration) (int, error) {
+ pollCalls++
+ if timeout > serialPollInterval {
+ t.Fatalf("poll timeout = %v, want <= %v", timeout, serialPollInterval)
+ }
+ now = now.Add(timeout)
+ switch pollCalls {
+ case 1, 2:
+ return 0, nil
+ default:
+ return 1, nil
+ }
+ }
+
+ written, err := serialWrite(context.Background(), serialConfig{}, []byte("OK"), 500*time.Millisecond)
+ if err != nil {
+ t.Fatalf("serialWrite() error = %v", err)
+ }
+ if written != 2 {
+ t.Fatalf("serialWrite() wrote %d bytes, want 2", written)
+ }
+ if pollCalls != 4 {
+ t.Fatalf("poll calls = %d, want 4", pollCalls)
+ }
+}
+
+func TestSerialWriteTimesOutAfterRepeatedEmptyPolls(t *testing.T) {
+ now := time.Unix(0, 0)
+ stubUnixSerialIO(t, &now)
+
+ unixSerialPollWrite = func(fd int, src []byte, timeout time.Duration) (int, error) {
+ now = now.Add(timeout)
+ return 0, nil
+ }
+
+ written, err := serialWrite(context.Background(), serialConfig{}, []byte("A"), 250*time.Millisecond)
+ if err == nil || err.Error() != "timeout while writing serial data" {
+ t.Fatalf("serialWrite() error = %v, want timeout", err)
+ }
+ if written != 0 {
+ t.Fatalf("serialWrite() wrote %d bytes, want 0", written)
+ }
+}
diff --git a/pkg/tools/hardware/serial_windows.go b/pkg/tools/hardware/serial_windows.go
new file mode 100644
index 000000000..31a215589
--- /dev/null
+++ b/pkg/tools/hardware/serial_windows.go
@@ -0,0 +1,247 @@
+//go:build windows
+
+package hardwaretools
+
+import (
+ "context"
+ "sort"
+ "strings"
+ "time"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+ "golang.org/x/sys/windows/registry"
+)
+
+var (
+ kernel32 = windows.NewLazySystemDLL("kernel32.dll")
+ procGetCommState = kernel32.NewProc("GetCommState")
+ procSetCommState = kernel32.NewProc("SetCommState")
+ procSetCommTimeouts = kernel32.NewProc("SetCommTimeouts")
+ procPurgeComm = kernel32.NewProc("PurgeComm")
+)
+
+const (
+ purgeTxClear = 0x0004
+ purgeRxClear = 0x0008
+
+ dcbFlagBinary = 0x00000001
+ dcbFlagParity = 0x00000002
+ dcbFlagOutxCtsFlow = 0x00000004
+ dcbFlagOutxDsrFlow = 0x00000008
+ dcbFlagDtrControlMask = 0x00000030
+ dcbFlagDsrSensitivity = 0x00000040
+ dcbFlagTXContinueOnXoff = 0x00000080
+ dcbFlagOutX = 0x00000100
+ dcbFlagInX = 0x00000200
+ dcbFlagRtsControlMask = 0x00003000
+)
+
+type dcb struct {
+ DCBlength uint32
+ BaudRate uint32
+ Flags uint32
+ Reserved uint16
+ XonLim uint16
+ XoffLim uint16
+ ByteSize byte
+ Parity byte
+ StopBits byte
+ XonChar byte
+ XoffChar byte
+ ErrorChar byte
+ EofChar byte
+ EvtChar byte
+ wReserved1 uint16
+}
+
+type commTimeouts struct {
+ ReadIntervalTimeout uint32
+ ReadTotalTimeoutMultiplier uint32
+ ReadTotalTimeoutConstant uint32
+ WriteTotalTimeoutMultiplier uint32
+ WriteTotalTimeoutConstant uint32
+}
+
+func serialListPorts() ([]serialPortInfo, error) {
+ key, err := registry.OpenKey(registry.LOCAL_MACHINE, `HARDWARE\DEVICEMAP\SERIALCOMM`, registry.QUERY_VALUE)
+ if err != nil {
+ if err == registry.ErrNotExist {
+ return nil, nil
+ }
+ return nil, err
+ }
+ defer key.Close()
+
+ names, err := key.ReadValueNames(-1)
+ if err != nil {
+ return nil, err
+ }
+
+ ports := make([]serialPortInfo, 0, len(names))
+ seen := make(map[string]struct{})
+ for _, name := range names {
+ value, _, err := key.GetStringValue(name)
+ if err != nil {
+ continue
+ }
+ portName := strings.TrimSpace(value)
+ if portName == "" {
+ continue
+ }
+ normalized := strings.ToUpper(portName)
+ if _, ok := seen[normalized]; ok {
+ continue
+ }
+ seen[normalized] = struct{}{}
+ ports = append(ports, serialPortInfo{
+ Name: normalized,
+ Path: normalized,
+ })
+ }
+
+ sort.Slice(ports, func(i, j int) bool {
+ return ports[i].Path < ports[j].Path
+ })
+ return ports, nil
+}
+
+func serialRead(ctx context.Context, cfg serialConfig, length int, timeout time.Duration) ([]byte, error) {
+ if err := serialContextErr(ctx); err != nil {
+ return nil, err
+ }
+
+ handle, err := openAndConfigureWindowsSerial(cfg, timeout)
+ if err != nil {
+ return nil, err
+ }
+ defer windows.CloseHandle(handle)
+
+ if err := serialContextErr(ctx); err != nil {
+ return nil, err
+ }
+
+ buf := make([]byte, length)
+ var read uint32
+ // Synchronous serial I/O on Windows cannot be interrupted once the syscall starts.
+ // COMMTIMEOUTS bounds how long turn cancellation may take to surface.
+ if err := windows.ReadFile(handle, buf, &read, nil); err != nil {
+ return nil, err
+ }
+ return buf[:read], nil
+}
+
+func serialWrite(ctx context.Context, cfg serialConfig, data []byte, timeout time.Duration) (int, error) {
+ if err := serialContextErr(ctx); err != nil {
+ return 0, err
+ }
+
+ handle, err := openAndConfigureWindowsSerial(cfg, timeout)
+ if err != nil {
+ return 0, err
+ }
+ defer windows.CloseHandle(handle)
+
+ if err := serialContextErr(ctx); err != nil {
+ return 0, err
+ }
+
+ return serialWriteAll(ctx, data, timeout, time.Now, func(chunk []byte) (int, error) {
+ var written uint32
+ // Like ReadFile above, this synchronous WriteFile call relies on COMMTIMEOUTS
+ // rather than context preemption once the syscall is in flight.
+ if err := windows.WriteFile(handle, chunk, &written, nil); err != nil {
+ return int(written), err
+ }
+ return int(written), nil
+ })
+}
+
+func openAndConfigureWindowsSerial(cfg serialConfig, timeout time.Duration) (windows.Handle, error) {
+ handle, err := windows.CreateFile(
+ windows.StringToUTF16Ptr(cfg.Port),
+ windows.GENERIC_READ|windows.GENERIC_WRITE,
+ 0,
+ nil,
+ windows.OPEN_EXISTING,
+ 0,
+ 0,
+ )
+ if err != nil {
+ return 0, err
+ }
+
+ if err := configureWindowsSerialPort(handle, cfg, timeout); err != nil {
+ windows.CloseHandle(handle)
+ return 0, err
+ }
+ return handle, nil
+}
+
+func configureWindowsSerialPort(handle windows.Handle, cfg serialConfig, timeout time.Duration) error {
+ state := dcb{DCBlength: uint32(unsafe.Sizeof(dcb{}))}
+ r1, _, err := procGetCommState.Call(uintptr(handle), uintptr(unsafe.Pointer(&state)))
+ if r1 == 0 {
+ return err
+ }
+
+ state.BaudRate = uint32(cfg.Baud)
+ state.ByteSize = byte(cfg.DataBits)
+ state.Flags = sanitizeWindowsSerialFlags(state.Flags)
+ state.Flags |= dcbFlagBinary
+
+ switch cfg.Parity {
+ case "even":
+ state.Parity = 2
+ state.Flags |= dcbFlagParity
+ case "odd":
+ state.Parity = 1
+ state.Flags |= dcbFlagParity
+ default:
+ state.Parity = 0
+ state.Flags &^= dcbFlagParity
+ }
+
+ switch cfg.StopBits {
+ case 2:
+ state.StopBits = 2
+ default:
+ state.StopBits = 0
+ }
+
+ r1, _, err = procSetCommState.Call(uintptr(handle), uintptr(unsafe.Pointer(&state)))
+ if r1 == 0 {
+ return err
+ }
+
+ timeoutMS := uint32(timeout / time.Millisecond)
+ if timeoutMS == 0 {
+ timeoutMS = 1
+ }
+ timeouts := commTimeouts{
+ ReadIntervalTimeout: timeoutMS,
+ ReadTotalTimeoutConstant: timeoutMS,
+ WriteTotalTimeoutConstant: timeoutMS,
+ ReadTotalTimeoutMultiplier: 0,
+ WriteTotalTimeoutMultiplier: 0,
+ }
+ r1, _, err = procSetCommTimeouts.Call(uintptr(handle), uintptr(unsafe.Pointer(&timeouts)))
+ if r1 == 0 {
+ return err
+ }
+
+ procPurgeComm.Call(uintptr(handle), uintptr(purgeRxClear|purgeTxClear))
+ return nil
+}
+
+func sanitizeWindowsSerialFlags(flags uint32) uint32 {
+ flags &^= dcbFlagOutxCtsFlow |
+ dcbFlagOutxDsrFlow |
+ dcbFlagDtrControlMask |
+ dcbFlagDsrSensitivity |
+ dcbFlagTXContinueOnXoff |
+ dcbFlagOutX |
+ dcbFlagInX |
+ dcbFlagRtsControlMask
+ return flags
+}
diff --git a/pkg/tools/hardware/serial_windows_test.go b/pkg/tools/hardware/serial_windows_test.go
new file mode 100644
index 000000000..ecb0addbd
--- /dev/null
+++ b/pkg/tools/hardware/serial_windows_test.go
@@ -0,0 +1,39 @@
+//go:build windows
+
+package hardwaretools
+
+import "testing"
+
+func TestSanitizeWindowsSerialFlags(t *testing.T) {
+ flags := uint32(
+ dcbFlagBinary |
+ dcbFlagParity |
+ dcbFlagOutxCtsFlow |
+ dcbFlagOutxDsrFlow |
+ dcbFlagDtrControlMask |
+ dcbFlagDsrSensitivity |
+ dcbFlagTXContinueOnXoff |
+ dcbFlagOutX |
+ dcbFlagInX |
+ dcbFlagRtsControlMask,
+ )
+
+ got := sanitizeWindowsSerialFlags(flags)
+
+ if got&dcbFlagBinary == 0 {
+ t.Fatal("sanitizeWindowsSerialFlags() should preserve fBinary")
+ }
+ if got&dcbFlagParity == 0 {
+ t.Fatal("sanitizeWindowsSerialFlags() should preserve fParity")
+ }
+ if got&(dcbFlagOutxCtsFlow|
+ dcbFlagOutxDsrFlow|
+ dcbFlagDtrControlMask|
+ dcbFlagDsrSensitivity|
+ dcbFlagTXContinueOnXoff|
+ dcbFlagOutX|
+ dcbFlagInX|
+ dcbFlagRtsControlMask) != 0 {
+ t.Fatalf("sanitizeWindowsSerialFlags() = %#x, want flow-control bits cleared", got)
+ }
+}
diff --git a/pkg/tools/hardware/serial_write_common_test.go b/pkg/tools/hardware/serial_write_common_test.go
new file mode 100644
index 000000000..398c1fde5
--- /dev/null
+++ b/pkg/tools/hardware/serial_write_common_test.go
@@ -0,0 +1,87 @@
+package hardwaretools
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+func TestSerialWriteAllRetriesPartialWritesUntilComplete(t *testing.T) {
+ now := time.Unix(0, 0)
+ calls := 0
+
+ written, err := serialWriteAll(context.Background(), []byte("PING"), time.Second, func() time.Time {
+ return now
+ }, func(chunk []byte) (int, error) {
+ calls++
+ now = now.Add(100 * time.Millisecond)
+ switch calls {
+ case 1:
+ if string(chunk) != "PING" {
+ t.Fatalf("first chunk = %q, want %q", chunk, "PING")
+ }
+ return 2, nil
+ case 2:
+ if string(chunk) != "NG" {
+ t.Fatalf("second chunk = %q, want %q", chunk, "NG")
+ }
+ return 2, nil
+ default:
+ t.Fatalf("unexpected extra write call %d", calls)
+ return 0, nil
+ }
+ })
+ if err != nil {
+ t.Fatalf("serialWriteAll() error = %v", err)
+ }
+ if written != 4 {
+ t.Fatalf("serialWriteAll() wrote %d bytes, want 4", written)
+ }
+}
+
+func TestSerialWriteAllTimesOutAfterZeroByteWrites(t *testing.T) {
+ now := time.Unix(0, 0)
+ calls := 0
+
+ written, err := serialWriteAll(context.Background(), []byte("A"), 250*time.Millisecond, func() time.Time {
+ return now
+ }, func(chunk []byte) (int, error) {
+ calls++
+ now = now.Add(100 * time.Millisecond)
+ return 0, nil
+ })
+ if err == nil || err.Error() != "timeout while writing serial data" {
+ t.Fatalf("serialWriteAll() error = %v, want timeout", err)
+ }
+ if written != 0 {
+ t.Fatalf("serialWriteAll() wrote %d bytes, want 0", written)
+ }
+ if calls != 3 {
+ t.Fatalf("write calls = %d, want 3", calls)
+ }
+}
+
+func TestSerialWriteAllReturnsContextCancellationAfterRetryBoundary(t *testing.T) {
+ now := time.Unix(0, 0)
+ ctx, cancel := context.WithCancel(context.Background())
+ calls := 0
+
+ written, err := serialWriteAll(ctx, []byte("A"), time.Second, func() time.Time {
+ return now
+ }, func(chunk []byte) (int, error) {
+ calls++
+ now = now.Add(100 * time.Millisecond)
+ cancel()
+ return 0, nil
+ })
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("serialWriteAll() error = %v, want context canceled", err)
+ }
+ if written != 0 {
+ t.Fatalf("serialWriteAll() wrote %d bytes, want 0", written)
+ }
+ if calls != 1 {
+ t.Fatalf("write calls = %d, want 1", calls)
+ }
+}
diff --git a/pkg/tools/hardware/shared.go b/pkg/tools/hardware/shared.go
new file mode 100644
index 000000000..3012f3e6c
--- /dev/null
+++ b/pkg/tools/hardware/shared.go
@@ -0,0 +1,13 @@
+package hardwaretools
+
+import toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
+
+type ToolResult = toolshared.ToolResult
+
+func ErrorResult(message string) *ToolResult {
+ return toolshared.ErrorResult(message)
+}
+
+func SilentResult(forLLM string) *ToolResult {
+ return toolshared.SilentResult(forLLM)
+}
diff --git a/pkg/tools/spi.go b/pkg/tools/hardware/spi.go
similarity index 98%
rename from pkg/tools/spi.go
rename to pkg/tools/hardware/spi.go
index 0ca17e84f..0bc0d8f72 100644
--- a/pkg/tools/spi.go
+++ b/pkg/tools/hardware/spi.go
@@ -1,4 +1,4 @@
-package tools
+package hardwaretools
import (
"context"
@@ -122,8 +122,6 @@ func (t *SPITool) list() *ToolResult {
// Helper function for SPI operations (used by platform-specific implementations)
// parseSPIArgs extracts and validates common SPI parameters
-//
-//nolint:unused // Used by spi_linux.go
func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) {
dev, ok := args["device"].(string)
if !ok || dev == "" {
@@ -160,3 +158,5 @@ func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8,
return dev, speed, mode, bits, ""
}
+
+var _ = parseSPIArgs
diff --git a/pkg/tools/spi_linux.go b/pkg/tools/hardware/spi_linux.go
similarity index 99%
rename from pkg/tools/spi_linux.go
rename to pkg/tools/hardware/spi_linux.go
index 9def73662..8502d6b9e 100644
--- a/pkg/tools/spi_linux.go
+++ b/pkg/tools/hardware/spi_linux.go
@@ -1,4 +1,4 @@
-package tools
+package hardwaretools
import (
"encoding/json"
diff --git a/pkg/tools/spi_other.go b/pkg/tools/hardware/spi_other.go
similarity index 94%
rename from pkg/tools/spi_other.go
rename to pkg/tools/hardware/spi_other.go
index 5d078ac3f..89fc99e67 100644
--- a/pkg/tools/spi_other.go
+++ b/pkg/tools/hardware/spi_other.go
@@ -1,6 +1,6 @@
//go:build !linux
-package tools
+package hardwaretools
// transfer is a stub for non-Linux platforms.
func (t *SPITool) transfer(args map[string]any) *ToolResult {
diff --git a/pkg/tools/hardware_facade.go b/pkg/tools/hardware_facade.go
new file mode 100644
index 000000000..b505c5a48
--- /dev/null
+++ b/pkg/tools/hardware_facade.go
@@ -0,0 +1,21 @@
+package tools
+
+import hardwaretools "github.com/sipeed/picoclaw/pkg/tools/hardware"
+
+type (
+ I2CTool = hardwaretools.I2CTool
+ SerialTool = hardwaretools.SerialTool
+ SPITool = hardwaretools.SPITool
+)
+
+func NewI2CTool() *I2CTool {
+ return hardwaretools.NewI2CTool()
+}
+
+func NewSPITool() *SPITool {
+ return hardwaretools.NewSPITool()
+}
+
+func NewSerialTool() *SerialTool {
+ return hardwaretools.NewSerialTool()
+}
diff --git a/pkg/tools/identifier_compat.go b/pkg/tools/identifier_compat.go
new file mode 100644
index 000000000..c5a6d9cf3
--- /dev/null
+++ b/pkg/tools/identifier_compat.go
@@ -0,0 +1,48 @@
+package tools
+
+import "strings"
+
+func sanitizeIdentifierComponent(s string) string {
+ const maxLen = 64
+
+ s = strings.ToLower(s)
+ var b strings.Builder
+ b.Grow(len(s))
+
+ prevUnderscore := false
+ for _, r := range s {
+ isAllowed := (r >= 'a' && r <= 'z') ||
+ (r >= '0' && r <= '9') ||
+ r == '_' || r == '-'
+
+ if !isAllowed {
+ if !prevUnderscore {
+ b.WriteRune('_')
+ prevUnderscore = true
+ }
+ continue
+ }
+
+ if r == '_' {
+ if prevUnderscore {
+ continue
+ }
+ prevUnderscore = true
+ } else {
+ prevUnderscore = false
+ }
+
+ b.WriteRune(r)
+ }
+
+ result := strings.Trim(b.String(), "_")
+ if result == "" {
+ result = "unnamed"
+ }
+
+ if len(result) > maxLen {
+ result = result[:maxLen]
+ }
+
+ return result
+}
diff --git a/pkg/tools/integration/helpers.go b/pkg/tools/integration/helpers.go
new file mode 100644
index 000000000..b34fbc6cd
--- /dev/null
+++ b/pkg/tools/integration/helpers.go
@@ -0,0 +1,134 @@
+package integrationtools
+
+import (
+ "fmt"
+ "math"
+ "mime"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "unicode"
+)
+
+var (
+ inlineMarkdownDataURLRe = regexp.MustCompile(`!\[[^\]]*\]\((data:[^)]+)\)`)
+ inlineRawDataURLRe = regexp.MustCompile(`data:[^;\s]+;base64,[A-Za-z0-9+/=\r\n]+`)
+)
+
+const (
+ largeBase64OmittedMessage = "[Tool returned a large base64-like payload; omitted from model context.]"
+ inlineMediaOmittedMessage = "[Tool returned inline media content; omitted from model context.]"
+)
+
+func sanitizeToolLLMContent(text string) string {
+ trimmed := strings.TrimSpace(text)
+ if trimmed == "" {
+ return text
+ }
+ if inlineMarkdownDataURLRe.MatchString(trimmed) || inlineRawDataURLRe.MatchString(trimmed) {
+ cleaned := inlineMarkdownDataURLRe.ReplaceAllString(trimmed, "")
+ cleaned = inlineRawDataURLRe.ReplaceAllString(cleaned, "")
+ cleaned = strings.TrimSpace(cleaned)
+ if cleaned == "" {
+ return inlineMediaOmittedMessage
+ }
+ return cleaned + "\n" + inlineMediaOmittedMessage
+ }
+ if looksLikeLargeBase64Payload(trimmed) {
+ return largeBase64OmittedMessage
+ }
+ return text
+}
+
+func looksLikeLargeBase64Payload(text string) bool {
+ trimmed := strings.TrimSpace(text)
+ if len(trimmed) < 1024 {
+ return false
+ }
+
+ nonSpace := 0
+ base64Like := 0
+ spaceCount := 0
+
+ for _, r := range trimmed {
+ if unicode.IsSpace(r) {
+ spaceCount++
+ continue
+ }
+ nonSpace++
+ if (r >= 'A' && r <= 'Z') ||
+ (r >= 'a' && r <= 'z') ||
+ (r >= '0' && r <= '9') ||
+ r == '+' || r == '/' || r == '=' {
+ base64Like++
+ }
+ }
+
+ if nonSpace == 0 {
+ return false
+ }
+
+ ratio := float64(base64Like) / float64(nonSpace)
+ return ratio >= 0.97 && spaceCount <= len(trimmed)/128
+}
+
+func extensionForMIMEType(mimeType string) string {
+ if mimeType == "" {
+ return ".bin"
+ }
+ if exts, err := mime.ExtensionsByType(mimeType); err == nil && len(exts) > 0 {
+ return exts[0]
+ }
+
+ switch strings.ToLower(mimeType) {
+ case "image/jpeg":
+ return ".jpg"
+ case "image/png":
+ return ".png"
+ case "image/gif":
+ return ".gif"
+ case "image/webp":
+ return ".webp"
+ case "audio/wav", "audio/x-wav":
+ return ".wav"
+ case "audio/mpeg":
+ return ".mp3"
+ case "audio/ogg":
+ return ".ogg"
+ case "video/mp4":
+ return ".mp4"
+ default:
+ return filepath.Ext(mimeType)
+ }
+}
+
+func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) {
+ raw, exists := args[key]
+ if !exists {
+ return defaultVal, nil
+ }
+
+ switch v := raw.(type) {
+ case float64:
+ if v != math.Trunc(v) {
+ return 0, fmt.Errorf("%s must be an integer, got float %v", key, v)
+ }
+ if v > math.MaxInt64 || v < math.MinInt64 {
+ return 0, fmt.Errorf("%s value %v overflows int64", key, v)
+ }
+ return int64(v), nil
+ case int:
+ return int64(v), nil
+ case int64:
+ return v, nil
+ case string:
+ parsed, err := strconv.ParseInt(v, 10, 64)
+ if err != nil {
+ return 0, fmt.Errorf("invalid integer format for %s parameter: %w", key, err)
+ }
+ return parsed, nil
+ default:
+ return 0, fmt.Errorf("unsupported type %T for %s parameter", raw, key)
+ }
+}
diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/integration/mcp_tool.go
similarity index 67%
rename from pkg/tools/mcp_tool.go
rename to pkg/tools/integration/mcp_tool.go
index 5bffb4e89..8cfc1de5e 100644
--- a/pkg/tools/mcp_tool.go
+++ b/pkg/tools/integration/mcp_tool.go
@@ -1,4 +1,4 @@
-package tools
+package integrationtools
import (
"context"
@@ -6,12 +6,17 @@ import (
"fmt"
"hash/fnv"
"os"
+ "path/filepath"
"strings"
"time"
+ "unicode/utf8"
"github.com/modelcontextprotocol/go-sdk/mcp"
+ runtimeevents "github.com/sipeed/picoclaw/pkg/events"
+ "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
+ toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
)
// MCPManager defines the interface for MCP manager operations
@@ -26,18 +31,31 @@ type MCPManager interface {
// MCPTool wraps an MCP tool to implement the Tool interface
type MCPTool struct {
- manager MCPManager
- serverName string
- tool *mcp.Tool
- mediaStore media.MediaStore
+ manager MCPManager
+ serverName string
+ tool *mcp.Tool
+ mediaStore media.MediaStore
+ workspace string
+ maxInlineTextRunes int
+ runtimeEvents runtimeevents.Bus
+}
+
+// MCPToolCallPayload describes MCP tool execution runtime events.
+type MCPToolCallPayload struct {
+ Server string `json:"server"`
+ Tool string `json:"tool"`
+ DurationMS int64 `json:"duration_ms,omitempty"`
+ IsError bool `json:"is_error,omitempty"`
+ Error string `json:"error,omitempty"`
}
// NewMCPTool creates a new MCP tool wrapper
func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool {
return &MCPTool{
- manager: manager,
- serverName: serverName,
- tool: tool,
+ manager: manager,
+ serverName: serverName,
+ tool: tool,
+ maxInlineTextRunes: maxMCPInlineTextRunes,
}
}
@@ -45,6 +63,23 @@ func (t *MCPTool) SetMediaStore(store media.MediaStore) {
t.mediaStore = store
}
+func (t *MCPTool) SetWorkspace(workspace string) {
+ t.workspace = strings.TrimSpace(workspace)
+}
+
+func (t *MCPTool) SetMaxInlineTextRunes(limit int) {
+ if limit > 0 {
+ t.maxInlineTextRunes = limit
+ }
+}
+
+// SetEventPublisher injects the runtime event bus used for MCP tool observations.
+func (t *MCPTool) SetEventPublisher(eventBus runtimeevents.Bus) {
+ t.runtimeEvents = eventBus
+}
+
+const maxMCPInlineTextRunes = 16 * 1024
+
// sanitizeIdentifierComponent normalizes a string so it can be safely used
// as part of a tool/function identifier for downstream providers.
// It:
@@ -143,6 +178,14 @@ func (t *MCPTool) Description() string {
return fmt.Sprintf("[MCP:%s] %s", t.serverName, desc)
}
+func (t *MCPTool) PromptMetadata() toolshared.PromptMetadata {
+ return toolshared.PromptMetadata{
+ Layer: toolshared.ToolPromptLayerCapability,
+ Slot: toolshared.ToolPromptSlotMCP,
+ Source: "mcp:" + sanitizeIdentifierComponent(t.serverName),
+ }
+}
+
// Parameters returns the tool parameters schema
func (t *MCPTool) Parameters() map[string]any {
// The InputSchema is already a JSON Schema object
@@ -210,26 +253,88 @@ func (t *MCPTool) Parameters() map[string]any {
// Execute executes the MCP tool
func (t *MCPTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ startedAt := time.Now()
+ t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallStart, startedAt, false, "")
+
result, err := t.manager.CallTool(ctx, t.serverName, t.tool.Name, args)
if err != nil {
+ t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, err.Error())
return ErrorResult(fmt.Sprintf("MCP tool execution failed: %v", err)).WithError(err)
}
if result == nil {
nilErr := fmt.Errorf("MCP tool returned nil result without error")
+ t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, nilErr.Error())
return ErrorResult("MCP tool execution failed: nil result").WithError(nilErr)
}
// Handle error result from server
if result.IsError {
errMsg := extractContentText(result.Content)
+ t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, errMsg)
return ErrorResult(fmt.Sprintf("MCP tool returned error: %s", errMsg)).
WithError(fmt.Errorf("MCP tool error: %s", errMsg))
}
+ t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, false, "")
return t.normalizeResultContent(ctx, result.Content)
}
+func (t *MCPTool) publishRuntimeEvent(
+ ctx context.Context,
+ kind runtimeevents.Kind,
+ startedAt time.Time,
+ isError bool,
+ errMsg string,
+) {
+ if t == nil || t.runtimeEvents == nil {
+ return
+ }
+
+ scope := runtimeevents.Scope{
+ AgentID: toolshared.ToolAgentID(ctx),
+ SessionKey: toolshared.ToolSessionKey(ctx),
+ Channel: toolshared.ToolChannel(ctx),
+ ChatID: toolshared.ToolChatID(ctx),
+ MessageID: toolshared.ToolMessageID(ctx),
+ }
+ payload := MCPToolCallPayload{
+ Server: t.serverName,
+ Tool: t.tool.Name,
+ DurationMS: time.Since(startedAt).Milliseconds(),
+ IsError: isError,
+ Error: errMsg,
+ }
+ severity := runtimeevents.SeverityInfo
+ if isError {
+ severity = runtimeevents.SeverityError
+ }
+
+ t.runtimeEvents.PublishNonBlocking(runtimeevents.Event{
+ Kind: kind,
+ Source: runtimeevents.Source{Component: "mcp", Name: t.serverName},
+ Scope: scope,
+ Severity: severity,
+ Payload: payload,
+ Attrs: mcpToolCallEventAttrs(payload),
+ })
+}
+
+func mcpToolCallEventAttrs(payload MCPToolCallPayload) map[string]any {
+ attrs := map[string]any{
+ "server": payload.Server,
+ "tool": payload.Tool,
+ "duration_ms": payload.DurationMS,
+ }
+ if payload.IsError {
+ attrs["is_error"] = payload.IsError
+ }
+ if payload.Error != "" {
+ attrs["error"] = payload.Error
+ }
+ return attrs
+}
+
// extractContentText extracts text from MCP content array
func extractContentText(content []mcp.Content) string {
var parts []string
@@ -255,14 +360,19 @@ func extractContentText(content []mcp.Content) string {
func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Content) *ToolResult {
llmParts := make([]string, 0, len(content))
+ rawTextParts := make([]string, 0, len(content))
mediaRefs := make([]string, 0, len(content))
for _, c := range content {
switch v := c.(type) {
case *mcp.TextContent:
- text := strings.TrimSpace(sanitizeToolLLMContent(v.Text))
- if text != "" {
- llmParts = append(llmParts, text)
+ rawText := strings.TrimSpace(v.Text)
+ if rawText != "" {
+ rawTextParts = append(rawTextParts, rawText)
+ }
+ safeText := strings.TrimSpace(sanitizeToolLLMContent(v.Text))
+ if safeText != "" {
+ llmParts = append(llmParts, safeText)
}
case *mcp.ImageContent:
ref, note := t.storeBinaryContent(
@@ -295,10 +405,13 @@ func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Cont
case *mcp.ResourceLink:
llmParts = append(llmParts, summarizeResourceLink(v))
case *mcp.EmbeddedResource:
- ref, note := t.storeEmbeddedResource(ctx, v)
+ ref, note, rawText := t.storeEmbeddedResource(ctx, v)
if ref != "" {
mediaRefs = append(mediaRefs, ref)
}
+ if rawText != "" {
+ rawTextParts = append(rawTextParts, rawText)
+ }
if note != "" {
llmParts = append(llmParts, note)
}
@@ -307,34 +420,105 @@ func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Cont
}
}
+ forLLM := strings.Join(compactStrings(llmParts), "\n")
+ rawText := strings.Join(compactStrings(rawTextParts), "\n")
+ if artifactResult := t.persistLargeTextArtifact(rawText); artifactResult != nil {
+ artifactResult.Media = mediaRefs
+ return artifactResult
+ }
+
result := &ToolResult{
- ForLLM: strings.Join(compactStrings(llmParts), "\n"),
+ ForLLM: forLLM,
Media: mediaRefs,
}
return result
}
-func (t *MCPTool) storeEmbeddedResource(ctx context.Context, content *mcp.EmbeddedResource) (string, string) {
+func (t *MCPTool) persistLargeTextArtifact(text string) *ToolResult {
+ text = strings.TrimSpace(text)
+ limit := t.maxInlineTextRunes
+ if limit <= 0 {
+ limit = maxMCPInlineTextRunes
+ }
+ size := utf8.RuneCountInString(text)
+ if text == "" || size <= limit || t.workspace == "" {
+ return nil
+ }
+
+ dir := filepath.Join(t.workspace, ".artifacts", "mcp")
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return t.largeTextArtifactFallback(text, err)
+ }
+ // TODO: Add lifecycle cleanup/retention for MCP artifact files.
+
+ pattern := fmt.Sprintf(
+ "%s_%s_*.txt",
+ sanitizeIdentifierComponent(t.serverName),
+ sanitizeIdentifierComponent(t.tool.Name),
+ )
+ tmpFile, err := os.CreateTemp(dir, pattern)
+ if err != nil {
+ return t.largeTextArtifactFallback(text, err)
+ }
+ path := tmpFile.Name()
+ if _, err = tmpFile.WriteString(text); err != nil {
+ _ = tmpFile.Close()
+ _ = os.Remove(path)
+ return t.largeTextArtifactFallback(text, err)
+ }
+ if err = tmpFile.Close(); err != nil {
+ _ = os.Remove(path)
+ return t.largeTextArtifactFallback(text, err)
+ }
+
+ return &ToolResult{
+ ForLLM: fmt.Sprintf(
+ "[MCP returned a large text result (%d chars); omitted from model context and saved as a local artifact.]",
+ size,
+ ),
+ ArtifactTags: []string{"[file:" + path + "]"},
+ }
+}
+
+func (t *MCPTool) largeTextArtifactFallback(text string, err error) *ToolResult {
+ size := utf8.RuneCountInString(text)
+ logger.WarnCF("tool", "Failed to persist large MCP text artifact", map[string]any{
+ "server": t.serverName,
+ "tool": t.tool.Name,
+ "chars": size,
+ "error": err.Error(),
+ })
+ return &ToolResult{
+ ForLLM: fmt.Sprintf(
+ "[MCP returned a large text result (%d chars); omitted from model context because artifact persistence failed.]",
+ size,
+ ),
+ }
+}
+
+func (t *MCPTool) storeEmbeddedResource(ctx context.Context, content *mcp.EmbeddedResource) (string, string, string) {
if content == nil || content.Resource == nil {
- return "", "[MCP returned an embedded resource without data.]"
+ return "", "[MCP returned an embedded resource without data.]", ""
}
resource := content.Resource
if len(resource.Blob) > 0 {
- return t.storeBinaryContent(
+ ref, note := t.storeBinaryContent(
ctx,
"resource",
normalizedMIMEType(resource.MIMEType),
resource.Blob,
content.Annotations,
)
+ return ref, note, ""
}
- if strings.TrimSpace(resource.Text) != "" {
- return "", sanitizeToolLLMContent(resource.Text)
+ rawText := strings.TrimSpace(resource.Text)
+ if rawText != "" {
+ return "", sanitizeToolLLMContent(resource.Text), rawText
}
- return "", summarizeEmbeddedResource(content)
+ return "", summarizeEmbeddedResource(content), ""
}
func (t *MCPTool) storeBinaryContent(
diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/integration/mcp_tool_test.go
similarity index 65%
rename from pkg/tools/mcp_tool_test.go
rename to pkg/tools/integration/mcp_tool_test.go
index 8bbac3bc7..7c961e1e1 100644
--- a/pkg/tools/mcp_tool_test.go
+++ b/pkg/tools/integration/mcp_tool_test.go
@@ -1,4 +1,4 @@
-package tools
+package integrationtools
import (
"context"
@@ -7,10 +7,13 @@ import (
"path/filepath"
"strings"
"testing"
+ "time"
"github.com/modelcontextprotocol/go-sdk/mcp"
+ runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/media"
+ toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
)
// MockMCPManager is a mock implementation of MCPManager interface for testing
@@ -104,6 +107,22 @@ func TestMCPTool_Name(t *testing.T) {
}
}
+func TestMCPTool_PromptMetadata(t *testing.T) {
+ manager := &MockMCPManager{}
+ tool := NewMCPTool(manager, "GitHub Server", &mcp.Tool{Name: "create_issue"})
+
+ metadata := tool.PromptMetadata()
+ if metadata.Layer != toolshared.ToolPromptLayerCapability {
+ t.Fatalf("metadata.Layer = %q, want %q", metadata.Layer, toolshared.ToolPromptLayerCapability)
+ }
+ if metadata.Slot != toolshared.ToolPromptSlotMCP {
+ t.Fatalf("metadata.Slot = %q, want %q", metadata.Slot, toolshared.ToolPromptSlotMCP)
+ }
+ if metadata.Source != "mcp:github_server" {
+ t.Fatalf("metadata.Source = %q, want mcp:github_server", metadata.Source)
+ }
+}
+
// TestMCPTool_Description verifies tool description generation
func TestMCPTool_Description(t *testing.T) {
tests := []struct {
@@ -282,6 +301,77 @@ func TestMCPTool_Execute_Success(t *testing.T) {
}
}
+func TestMCPTool_Execute_PublishesRuntimeEvents(t *testing.T) {
+ eventBus := runtimeevents.NewBus()
+ defer func() {
+ if err := eventBus.Close(); err != nil {
+ t.Errorf("event bus close failed: %v", err)
+ }
+ }()
+
+ _, eventsCh, err := eventBus.Channel().OfKind(
+ runtimeevents.KindMCPToolCallStart,
+ runtimeevents.KindMCPToolCallEnd,
+ ).SubscribeChan(t.Context(), runtimeevents.SubscribeOptions{Name: "mcp-tool-events", Buffer: 2})
+ if err != nil {
+ t.Fatalf("SubscribeChan failed: %v", err)
+ }
+
+ manager := &MockMCPManager{}
+ mcpTool := NewMCPTool(manager, "github", &mcp.Tool{Name: "search_repos"})
+ mcpTool.SetEventPublisher(eventBus)
+
+ ctx := toolshared.WithToolContext(context.Background(), "telegram", "chat-1")
+ ctx = toolshared.WithToolMessageContext(ctx, "msg-1", "")
+ ctx = toolshared.WithToolSessionContext(ctx, "main", "session-1", nil)
+ result := mcpTool.Execute(ctx, map[string]any{"query": "picoclaw"})
+ if result == nil || result.IsError {
+ t.Fatalf("Execute result = %+v", result)
+ }
+
+ started := receiveMCPToolRuntimeEvent(t, eventsCh)
+ if started.Kind != runtimeevents.KindMCPToolCallStart ||
+ started.Scope.AgentID != "main" ||
+ started.Scope.SessionKey != "session-1" ||
+ started.Scope.Channel != "telegram" ||
+ started.Scope.ChatID != "chat-1" ||
+ started.Scope.MessageID != "msg-1" {
+ t.Fatalf("started event = %+v", started)
+ }
+
+ ended := receiveMCPToolRuntimeEvent(t, eventsCh)
+ if ended.Kind != runtimeevents.KindMCPToolCallEnd || ended.Severity != runtimeevents.SeverityInfo {
+ t.Fatalf("ended event = %+v", ended)
+ }
+ payload, ok := ended.Payload.(MCPToolCallPayload)
+ if !ok {
+ t.Fatalf("ended payload = %T, want MCPToolCallPayload", ended.Payload)
+ }
+ if payload.Server != "github" || payload.Tool != "search_repos" || payload.IsError {
+ t.Fatalf("ended payload = %+v", payload)
+ }
+ if ended.Attrs["server"] != "github" ||
+ ended.Attrs["tool"] != "search_repos" ||
+ ended.Attrs["duration_ms"] == nil {
+ t.Fatalf("ended attrs = %#v", ended.Attrs)
+ }
+}
+
+func receiveMCPToolRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event {
+ t.Helper()
+
+ select {
+ case evt, ok := <-ch:
+ if !ok {
+ t.Fatal("runtime event channel closed before expected event")
+ }
+ return evt
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for runtime event")
+ return runtimeevents.Event{}
+ }
+}
+
// TestMCPTool_Execute_ManagerError tests execution when manager returns error
func TestMCPTool_Execute_ManagerError(t *testing.T) {
manager := &MockMCPManager{
@@ -634,3 +724,177 @@ func TestMCPTool_Execute_LargeBase64TextIsOmittedFromContext(t *testing.T) {
t.Fatalf("expected sanitized large base64 note, got %q", result.ForLLM)
}
}
+
+func TestMCPTool_Execute_LargeBase64TextArtifactPreservesRawPayload(t *testing.T) {
+ workspace := t.TempDir()
+ largeBase64 := strings.Repeat("QUJD", 400)
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: largeBase64},
+ },
+ }, nil
+ },
+ }
+
+ mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"})
+ mcpTool.SetWorkspace(workspace)
+ mcpTool.SetMaxInlineTextRunes(32)
+
+ result := mcpTool.Execute(context.Background(), nil)
+
+ if !strings.Contains(result.ForLLM, "saved as a local artifact") {
+ t.Fatalf("expected artifact note, got %q", result.ForLLM)
+ }
+ if result.ForLLM == largeBase64OmittedMessage {
+ t.Fatalf("expected artifact note instead of sanitized base64 placeholder")
+ }
+ if len(result.ArtifactTags) != 1 {
+ t.Fatalf("expected 1 artifact tag, got %d", len(result.ArtifactTags))
+ }
+ tag := result.ArtifactTags[0]
+ const prefix = "[file:"
+ if !strings.HasPrefix(tag, prefix) || !strings.HasSuffix(tag, "]") {
+ t.Fatalf("expected file artifact tag, got %q", tag)
+ }
+ path := strings.TrimSuffix(strings.TrimPrefix(tag, prefix), "]")
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("expected artifact file to be readable: %v", err)
+ }
+ if string(data) != largeBase64 {
+ t.Fatalf("expected artifact file contents to preserve raw MCP payload")
+ }
+}
+
+func TestMCPTool_Execute_LargeTextStoredAsArtifact(t *testing.T) {
+ workspace := t.TempDir()
+ largeText := strings.Repeat("This is a large MCP text payload.\n", 800)
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: largeText},
+ },
+ }, nil
+ },
+ }
+
+ mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"})
+ mcpTool.SetWorkspace(workspace)
+
+ result := mcpTool.Execute(context.Background(), nil)
+
+ if strings.Contains(result.ForLLM, "This is a large MCP text payload") {
+ t.Fatalf("expected large MCP text to be omitted from ForLLM, got %q", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "saved as a local artifact") {
+ t.Fatalf("expected artifact note, got %q", result.ForLLM)
+ }
+ if len(result.ArtifactTags) != 1 {
+ t.Fatalf("expected 1 artifact tag, got %d", len(result.ArtifactTags))
+ }
+ tag := result.ArtifactTags[0]
+ const prefix = "[file:"
+ if !strings.HasPrefix(tag, prefix) || !strings.HasSuffix(tag, "]") {
+ t.Fatalf("expected file artifact tag, got %q", tag)
+ }
+ path := strings.TrimSuffix(strings.TrimPrefix(tag, prefix), "]")
+ if !strings.HasPrefix(path, workspace) {
+ t.Fatalf("expected artifact inside workspace, got %q", path)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("expected artifact file to be readable: %v", err)
+ }
+ if string(data) != strings.TrimSpace(largeText) {
+ t.Fatalf("expected artifact file contents to match source text")
+ }
+}
+
+func TestMCPTool_Execute_CustomInlineTextThreshold(t *testing.T) {
+ workspace := t.TempDir()
+ text := strings.Repeat("small custom threshold text\n", 20)
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: text},
+ },
+ }, nil
+ },
+ }
+
+ mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"})
+ mcpTool.SetWorkspace(workspace)
+ mcpTool.SetMaxInlineTextRunes(32)
+
+ result := mcpTool.Execute(context.Background(), nil)
+
+ if len(result.ArtifactTags) != 1 {
+ t.Fatalf("expected custom threshold to persist artifact, got %+v", result)
+ }
+ if strings.Contains(result.ForLLM, "small custom threshold text") {
+ t.Fatalf("expected text to be omitted from ForLLM, got %q", result.ForLLM)
+ }
+}
+
+func TestMCPTool_Execute_LargeTextArtifactFailureStillOmitsContext(t *testing.T) {
+ workspaceRoot := t.TempDir()
+ workspaceFile := filepath.Join(workspaceRoot, "not-a-directory")
+ if err := os.WriteFile(workspaceFile, []byte("x"), 0o600); err != nil {
+ t.Fatalf("failed to create workspace file: %v", err)
+ }
+
+ largeText := strings.Repeat("This is a large MCP text payload.\n", 800)
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: largeText},
+ },
+ }, nil
+ },
+ }
+
+ mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"})
+ mcpTool.SetWorkspace(workspaceFile)
+
+ result := mcpTool.Execute(context.Background(), nil)
+
+ if strings.Contains(result.ForLLM, "This is a large MCP text payload") {
+ t.Fatalf("expected large MCP text to be omitted from ForLLM, got %q", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "artifact persistence failed") {
+ t.Fatalf("expected persistence failure note, got %q", result.ForLLM)
+ }
+ if len(result.ArtifactTags) != 0 {
+ t.Fatalf("expected no artifact tags on persistence failure, got %+v", result.ArtifactTags)
+ }
+}
+
+func TestMCPTool_Execute_WhitespaceWorkspaceDisablesArtifactPersistence(t *testing.T) {
+ largeText := strings.Repeat("This is a large MCP text payload.\n", 800)
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: largeText},
+ },
+ }, nil
+ },
+ }
+
+ mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"})
+ mcpTool.SetWorkspace(" \n\t ")
+
+ result := mcpTool.Execute(context.Background(), nil)
+
+ if len(result.ArtifactTags) != 0 {
+ t.Fatalf("expected no artifact tags for whitespace workspace, got %+v", result.ArtifactTags)
+ }
+ if !strings.Contains(result.ForLLM, "This is a large MCP text payload") {
+ t.Fatalf("expected large text to remain inline when workspace is blank, got %q", result.ForLLM)
+ }
+}
diff --git a/pkg/tools/integration/message.go b/pkg/tools/integration/message.go
new file mode 100644
index 000000000..98d87bcb3
--- /dev/null
+++ b/pkg/tools/integration/message.go
@@ -0,0 +1,143 @@
+package integrationtools
+
+import (
+ "context"
+ "fmt"
+ "sync"
+)
+
+type SendCallbackWithContext func(ctx context.Context, channel, chatID, content, replyToMessageID string) error
+
+// sentTarget records the channel+chatID that the message tool sent to.
+type sentTarget struct {
+ Channel string
+ ChatID string
+}
+
+type MessageTool struct {
+ sendCallback SendCallbackWithContext
+ mu sync.Mutex
+ // sentTargets tracks targets sent to in the current round, keyed by session key
+ // to support parallel turns for different sessions.
+ sentTargets map[string][]sentTarget
+}
+
+func NewMessageTool() *MessageTool {
+ return &MessageTool{
+ sentTargets: make(map[string][]sentTarget),
+ }
+}
+
+func (t *MessageTool) Name() string {
+ return "message"
+}
+
+func (t *MessageTool) Description() string {
+ return "Send a message to user on a chat channel. Use this when you want to communicate something."
+}
+
+func (t *MessageTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "content": map[string]any{
+ "type": "string",
+ "description": "The message content to send",
+ },
+ "channel": map[string]any{
+ "type": "string",
+ "description": "Optional: target channel (telegram, whatsapp, etc.)",
+ },
+ "chat_id": map[string]any{
+ "type": "string",
+ "description": "Optional: target chat/user ID",
+ },
+ "reply_to_message_id": map[string]any{
+ "type": "string",
+ "description": "Optional: reply target message ID for channels that support threaded replies",
+ },
+ },
+ "required": []string{"content"},
+ }
+}
+
+// ResetSentInRound resets the per-round send tracker for the given session key.
+// Called by the agent loop at the start of each inbound message processing round.
+func (t *MessageTool) ResetSentInRound(sessionKey string) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ // Delete the key entirely to prevent unbounded map growth over time
+ // with many unique sessions. Truncating the slice keeps the key alive.
+ delete(t.sentTargets, sessionKey)
+}
+
+// HasSentInRound returns true if the message tool sent a message during the current round.
+func (t *MessageTool) HasSentInRound(sessionKey string) bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return len(t.sentTargets[sessionKey]) > 0
+}
+
+// HasSentTo returns true if the message tool sent to the specific channel+chatID
+// during the current round. Used by PublishResponseIfNeeded to avoid suppressing
+// the final response when the message tool only sent to a different conversation.
+func (t *MessageTool) HasSentTo(sessionKey, channel, chatID string) bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ for _, st := range t.sentTargets[sessionKey] {
+ if st.Channel == channel && st.ChatID == chatID {
+ return true
+ }
+ }
+ return false
+}
+
+func (t *MessageTool) SetSendCallback(callback SendCallbackWithContext) {
+ t.sendCallback = callback
+}
+
+func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ content, ok := args["content"].(string)
+ if !ok {
+ return &ToolResult{ForLLM: "content is required", IsError: true}
+ }
+
+ channel, _ := args["channel"].(string)
+ chatID, _ := args["chat_id"].(string)
+ replyToMessageID, _ := args["reply_to_message_id"].(string)
+
+ if channel == "" {
+ channel = ToolChannel(ctx)
+ }
+ if chatID == "" {
+ chatID = ToolChatID(ctx)
+ }
+
+ if channel == "" || chatID == "" {
+ return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true}
+ }
+
+ if t.sendCallback == nil {
+ return &ToolResult{ForLLM: "Message sending not configured", IsError: true}
+ }
+
+ if err := t.sendCallback(ctx, channel, chatID, content, replyToMessageID); err != nil {
+ return &ToolResult{
+ ForLLM: fmt.Sprintf("sending message: %v", err),
+ IsError: true,
+ Err: err,
+ }
+ }
+
+ sessionKey := ToolSessionKey(ctx)
+ t.mu.Lock()
+ t.sentTargets[sessionKey] = append(t.sentTargets[sessionKey], sentTarget{Channel: channel, ChatID: chatID})
+ t.mu.Unlock()
+
+ // Silent: user already received the message directly
+ return &ToolResult{
+ ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),
+ Silent: true,
+ }
+}
diff --git a/pkg/tools/message_test.go b/pkg/tools/integration/message_test.go
similarity index 68%
rename from pkg/tools/message_test.go
rename to pkg/tools/integration/message_test.go
index 05630972e..c7b7d2b6e 100644
--- a/pkg/tools/message_test.go
+++ b/pkg/tools/integration/message_test.go
@@ -1,19 +1,25 @@
-package tools
+package integrationtools
import (
"context"
"errors"
"testing"
+
+ "github.com/sipeed/picoclaw/pkg/session"
)
func TestMessageTool_Execute_Success(t *testing.T) {
tool := NewMessageTool()
var sentChannel, sentChatID, sentContent string
- tool.SetSendCallback(func(channel, chatID, content string) error {
+ tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
sentChannel = channel
sentChatID = chatID
sentContent = content
+ if ToolAgentID(ctx) != "" || ToolSessionKey(ctx) != "" || ToolSessionScope(ctx) != nil {
+ t.Fatalf("expected empty turn metadata in basic context, got agent=%q session=%q scope=%+v",
+ ToolAgentID(ctx), ToolSessionKey(ctx), ToolSessionScope(ctx))
+ }
return nil
})
@@ -61,7 +67,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
tool := NewMessageTool()
var sentChannel, sentChatID string
- tool.SetSendCallback(func(channel, chatID, content string) error {
+ tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
sentChannel = channel
sentChatID = chatID
return nil
@@ -96,7 +102,7 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
tool := NewMessageTool()
sendErr := errors.New("network error")
- tool.SetSendCallback(func(channel, chatID, content string) error {
+ tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
return sendErr
})
@@ -149,7 +155,7 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
tool := NewMessageTool()
// No WithToolContext — channel/chatID are empty
- tool.SetSendCallback(func(channel, chatID, content string) error {
+ tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
return nil
})
@@ -251,4 +257,75 @@ func TestMessageTool_Parameters(t *testing.T) {
if chatIDProp["type"] != "string" {
t.Error("Expected chat_id type to be 'string'")
}
+
+ // Check reply_to_message_id property (optional)
+ replyToProp, ok := props["reply_to_message_id"].(map[string]any)
+ if !ok {
+ t.Error("Expected 'reply_to_message_id' property")
+ }
+ if replyToProp["type"] != "string" {
+ t.Error("Expected reply_to_message_id type to be 'string'")
+ }
+}
+
+func TestMessageTool_Execute_WithReplyToMessageID(t *testing.T) {
+ tool := NewMessageTool()
+
+ var sentReplyTo string
+ tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
+ sentReplyTo = replyToMessageID
+ return nil
+ })
+
+ ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
+ args := map[string]any{
+ "content": "Reply test",
+ "reply_to_message_id": "msg-123",
+ }
+
+ result := tool.Execute(ctx, args)
+ if result.IsError {
+ t.Fatalf("expected success, got error: %s", result.ForLLM)
+ }
+ if sentReplyTo != "msg-123" {
+ t.Fatalf("expected reply_to_message_id msg-123, got %q", sentReplyTo)
+ }
+}
+
+func TestMessageTool_Execute_PropagatesTurnSessionMetadata(t *testing.T) {
+ tool := NewMessageTool()
+
+ var gotAgentID, gotSessionKey string
+ var gotScope *session.SessionScope
+ tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
+ gotAgentID = ToolAgentID(ctx)
+ gotSessionKey = ToolSessionKey(ctx)
+ gotScope = ToolSessionScope(ctx)
+ return nil
+ })
+
+ ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
+ ctx = WithToolSessionContext(ctx, "main", "sk_v1_tool", &session.SessionScope{
+ Version: session.ScopeVersionV1,
+ AgentID: "main",
+ Channel: "telegram",
+ Dimensions: []string{"chat"},
+ Values: map[string]string{
+ "chat": "direct:test-chat-id",
+ },
+ })
+
+ result := tool.Execute(ctx, map[string]any{"content": "Hello, world!"})
+ if result.IsError {
+ t.Fatalf("expected success, got error: %s", result.ForLLM)
+ }
+ if gotAgentID != "main" {
+ t.Fatalf("ToolAgentID() = %q, want main", gotAgentID)
+ }
+ if gotSessionKey != "sk_v1_tool" {
+ t.Fatalf("ToolSessionKey() = %q, want sk_v1_tool", gotSessionKey)
+ }
+ if gotScope == nil || gotScope.Values["chat"] != "direct:test-chat-id" {
+ t.Fatalf("ToolSessionScope() = %+v, want chat scope", gotScope)
+ }
}
diff --git a/pkg/tools/integration/reaction.go b/pkg/tools/integration/reaction.go
new file mode 100644
index 000000000..5a8dc87be
--- /dev/null
+++ b/pkg/tools/integration/reaction.go
@@ -0,0 +1,87 @@
+package integrationtools
+
+import (
+ "context"
+ "fmt"
+)
+
+type ReactionCallback func(ctx context.Context, channel, chatID, messageID string) error
+
+type ReactionTool struct {
+ reactionCallback ReactionCallback
+}
+
+func NewReactionTool() *ReactionTool {
+ return &ReactionTool{}
+}
+
+func (t *ReactionTool) Name() string {
+ return "reaction"
+}
+
+func (t *ReactionTool) Description() string {
+ return "Add a reaction to a message. Defaults to the current inbound message when message_id is omitted."
+}
+
+func (t *ReactionTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "message_id": map[string]any{
+ "type": "string",
+ "description": "Optional: target message ID; defaults to the current inbound message",
+ },
+ "channel": map[string]any{
+ "type": "string",
+ "description": "Optional: target channel (telegram, whatsapp, etc.)",
+ },
+ "chat_id": map[string]any{
+ "type": "string",
+ "description": "Optional: target chat/user ID",
+ },
+ },
+ }
+}
+
+func (t *ReactionTool) SetReactionCallback(callback ReactionCallback) {
+ t.reactionCallback = callback
+}
+
+func (t *ReactionTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ channel, _ := args["channel"].(string)
+ chatID, _ := args["chat_id"].(string)
+ messageID, _ := args["message_id"].(string)
+
+ if channel == "" {
+ channel = ToolChannel(ctx)
+ }
+ if chatID == "" {
+ chatID = ToolChatID(ctx)
+ }
+ if messageID == "" {
+ messageID = ToolMessageID(ctx)
+ }
+
+ if channel == "" || chatID == "" {
+ return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true}
+ }
+ if messageID == "" {
+ return &ToolResult{ForLLM: "message_id is required", IsError: true}
+ }
+ if t.reactionCallback == nil {
+ return &ToolResult{ForLLM: "Reaction not configured", IsError: true}
+ }
+
+ if err := t.reactionCallback(ctx, channel, chatID, messageID); err != nil {
+ return &ToolResult{
+ ForLLM: fmt.Sprintf("adding reaction: %v", err),
+ IsError: true,
+ Err: err,
+ }
+ }
+
+ return &ToolResult{
+ ForLLM: fmt.Sprintf("Reaction added to %s:%s message %s", channel, chatID, messageID),
+ Silent: true,
+ }
+}
diff --git a/pkg/tools/integration/reaction_test.go b/pkg/tools/integration/reaction_test.go
new file mode 100644
index 000000000..f579fd914
--- /dev/null
+++ b/pkg/tools/integration/reaction_test.go
@@ -0,0 +1,96 @@
+package integrationtools
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+func TestReactionTool_Execute_UsesContextMessageIDByDefault(t *testing.T) {
+ tool := NewReactionTool()
+
+ var gotChannel, gotChatID, gotMessageID string
+ tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error {
+ gotChannel = channel
+ gotChatID = chatID
+ gotMessageID = messageID
+ return nil
+ })
+
+ ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-100", "")
+ result := tool.Execute(ctx, map[string]any{})
+ if result.IsError {
+ t.Fatalf("expected success, got error: %s", result.ForLLM)
+ }
+ if gotChannel != "telegram" || gotChatID != "chat-1" || gotMessageID != "msg-100" {
+ t.Fatalf("unexpected callback args: channel=%q chatID=%q messageID=%q", gotChannel, gotChatID, gotMessageID)
+ }
+}
+
+func TestReactionTool_Execute_AllowsExplicitMessageIDOverride(t *testing.T) {
+ tool := NewReactionTool()
+
+ var gotMessageID string
+ tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error {
+ gotMessageID = messageID
+ return nil
+ })
+
+ ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-context", "")
+ result := tool.Execute(ctx, map[string]any{"message_id": "msg-explicit"})
+ if result.IsError {
+ t.Fatalf("expected success, got error: %s", result.ForLLM)
+ }
+ if gotMessageID != "msg-explicit" {
+ t.Fatalf("expected explicit message id, got %q", gotMessageID)
+ }
+}
+
+func TestReactionTool_Execute_MissingMessageID(t *testing.T) {
+ tool := NewReactionTool()
+ tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { return nil })
+
+ ctx := WithToolContext(context.Background(), "telegram", "chat-1")
+ result := tool.Execute(ctx, map[string]any{})
+ if !result.IsError {
+ t.Fatal("expected error")
+ }
+ if result.ForLLM != "message_id is required" {
+ t.Fatalf("unexpected error message: %q", result.ForLLM)
+ }
+}
+
+func TestReactionTool_Execute_CallbackError(t *testing.T) {
+ tool := NewReactionTool()
+ tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error {
+ return errors.New("unsupported")
+ })
+
+ ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-100", "")
+ result := tool.Execute(ctx, map[string]any{})
+ if !result.IsError {
+ t.Fatal("expected error")
+ }
+ if result.Err == nil {
+ t.Fatal("expected wrapped error")
+ }
+}
+
+func TestReactionTool_Parameters(t *testing.T) {
+ tool := NewReactionTool()
+ params := tool.Parameters()
+
+ props, ok := params["properties"].(map[string]any)
+ if !ok {
+ t.Fatal("expected properties map")
+ }
+ if _, ok := props["message_id"]; !ok {
+ t.Fatal("expected message_id parameter")
+ }
+ if _, ok := props["channel"]; !ok {
+ t.Fatal("expected channel parameter")
+ }
+ if _, ok := props["chat_id"]; !ok {
+ t.Fatal("expected chat_id parameter")
+ }
+}
diff --git a/pkg/tools/integration/shared.go b/pkg/tools/integration/shared.go
new file mode 100644
index 000000000..cc6aa3f28
--- /dev/null
+++ b/pkg/tools/integration/shared.go
@@ -0,0 +1,77 @@
+package integrationtools
+
+import (
+ "context"
+
+ "github.com/sipeed/picoclaw/pkg/session"
+ toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
+)
+
+type (
+ Tool = toolshared.Tool
+ ToolResult = toolshared.ToolResult
+ AsyncCallback = toolshared.AsyncCallback
+)
+
+func WithToolContext(ctx context.Context, channel, chatID string) context.Context {
+ return toolshared.WithToolContext(ctx, channel, chatID)
+}
+
+func WithToolInboundContext(
+ ctx context.Context,
+ channel, chatID, messageID, replyToMessageID string,
+) context.Context {
+ return toolshared.WithToolInboundContext(ctx, channel, chatID, messageID, replyToMessageID)
+}
+
+func WithToolSessionContext(
+ ctx context.Context,
+ agentID, sessionKey string,
+ scope *session.SessionScope,
+) context.Context {
+ return toolshared.WithToolSessionContext(ctx, agentID, sessionKey, scope)
+}
+
+func ToolChannel(ctx context.Context) string {
+ return toolshared.ToolChannel(ctx)
+}
+
+func ToolChatID(ctx context.Context) string {
+ return toolshared.ToolChatID(ctx)
+}
+
+func ToolMessageID(ctx context.Context) string {
+ return toolshared.ToolMessageID(ctx)
+}
+
+func ToolAgentID(ctx context.Context) string {
+ return toolshared.ToolAgentID(ctx)
+}
+
+func ToolSessionKey(ctx context.Context) string {
+ return toolshared.ToolSessionKey(ctx)
+}
+
+func ToolSessionScope(ctx context.Context) *session.SessionScope {
+ return toolshared.ToolSessionScope(ctx)
+}
+
+func ErrorResult(message string) *ToolResult {
+ return toolshared.ErrorResult(message)
+}
+
+func SilentResult(forLLM string) *ToolResult {
+ return toolshared.SilentResult(forLLM)
+}
+
+func NewToolResult(forLLM string) *ToolResult {
+ return toolshared.NewToolResult(forLLM)
+}
+
+func UserResult(content string) *ToolResult {
+ return toolshared.UserResult(content)
+}
+
+func MediaResult(forLLM string, mediaRefs []string) *ToolResult {
+ return toolshared.MediaResult(forLLM, mediaRefs)
+}
diff --git a/pkg/tools/skills_install.go b/pkg/tools/integration/skills_install.go
similarity index 56%
rename from pkg/tools/skills_install.go
rename to pkg/tools/integration/skills_install.go
index 71bfe730b..1824f2c0a 100644
--- a/pkg/tools/skills_install.go
+++ b/pkg/tools/integration/skills_install.go
@@ -1,4 +1,4 @@
-package tools
+package integrationtools
import (
"context"
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"sync"
"time"
@@ -15,6 +16,10 @@ import (
"github.com/sipeed/picoclaw/pkg/utils"
)
+const defaultSkillRegistryName = "github"
+
+var persistInstalledSkillOriginMeta = writeOriginMeta
+
// InstallSkillTool allows the LLM agent to install skills from registries.
// It shares the same RegistryManager that FindSkillsTool uses,
// so all registries configured in config are available for installation.
@@ -40,7 +45,7 @@ func (t *InstallSkillTool) Name() string {
}
func (t *InstallSkillTool) Description() string {
- return "Install a skill from a registry by slug. Downloads and extracts the skill into the workspace. Use find_skills first to discover available skills."
+ return "Install a skill from a registry by slug. Defaults to GitHub when registry is omitted. Downloads and extracts the skill into the workspace. Use find_skills first to discover available skills."
}
func (t *InstallSkillTool) Parameters() map[string]any {
@@ -57,14 +62,14 @@ func (t *InstallSkillTool) Parameters() map[string]any {
},
"registry": map[string]any{
"type": "string",
- "description": "Registry to install from (required, e.g., 'clawhub')",
+ "description": "Registry to install from (optional, defaults to 'github')",
},
"force": map[string]any{
"type": "boolean",
"description": "Force reinstall if skill already exists (default false)",
},
},
- "required": []string{"slug", "registry"},
+ "required": []string{"slug"},
}
}
@@ -74,45 +79,86 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
t.mu.Lock()
defer t.mu.Unlock()
- // Validate slug
slug, _ := args["slug"].(string)
- if err := utils.ValidateSkillIdentifier(slug); err != nil {
- return ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error()))
+ if strings.TrimSpace(slug) == "" {
+ return ErrorResult("identifier is required and must be a non-empty string")
}
// Validate registry
registryName, _ := args["registry"].(string)
+ if registryName == "" {
+ registryName = defaultSkillRegistryName
+ }
if err := utils.ValidateSkillIdentifier(registryName); err != nil {
return ErrorResult(fmt.Sprintf("invalid registry %q: error: %s", registryName, err.Error()))
}
- version, _ := args["version"].(string)
- force, _ := args["force"].(bool)
-
- // Check if already installed.
- skillsDir := filepath.Join(t.workspace, "skills")
- targetDir := filepath.Join(skillsDir, slug)
-
- if !force {
- if _, err := os.Stat(targetDir); err == nil {
- return ErrorResult(
- fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir),
- )
- }
- } else {
- // Force: remove existing if present.
- os.RemoveAll(targetDir)
- }
-
// Resolve which registry to use.
registry := t.registryMgr.GetRegistry(registryName)
if registry == nil {
return ErrorResult(fmt.Sprintf("registry %q not found", registryName))
}
+ // Validate target and resolve install directory.
+ dirName, err := registry.ResolveInstallDirName(slug)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error()))
+ }
+
+ version, _ := args["version"].(string)
+ force, _ := args["force"].(bool)
+
+ // Check if already installed.
+ skillsDir := filepath.Join(t.workspace, "skills")
+ targetDir := filepath.Join(skillsDir, dirName)
+ backupDir := ""
+ restorePreviousInstall := func() {
+ if backupDir == "" {
+ return
+ }
+ if rmErr := os.RemoveAll(targetDir); rmErr != nil {
+ logger.ErrorCF("tool", "Failed to remove failed install before restore",
+ map[string]any{
+ "tool": "install_skill",
+ "target_dir": targetDir,
+ "error": rmErr.Error(),
+ })
+ return
+ }
+ if restoreErr := os.Rename(backupDir, targetDir); restoreErr != nil {
+ logger.ErrorCF("tool", "Failed to restore previous install after failed reinstall",
+ map[string]any{
+ "tool": "install_skill",
+ "backup_dir": backupDir,
+ "target_dir": targetDir,
+ "error": restoreErr.Error(),
+ })
+ return
+ }
+ backupDir = ""
+ }
+
+ if !force {
+ if _, statErr := os.Stat(targetDir); statErr == nil {
+ return ErrorResult(
+ fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir),
+ )
+ }
+ } else {
+ if _, statErr := os.Stat(targetDir); statErr == nil {
+ backupDir = filepath.Join(skillsDir, fmt.Sprintf(".%s.picoclaw-backup-%d", dirName, time.Now().UnixNano()))
+ if renameErr := os.Rename(targetDir, backupDir); renameErr != nil {
+ return ErrorResult(fmt.Sprintf("failed to prepare reinstall for %q: %v", slug, renameErr))
+ }
+ } else if !os.IsNotExist(statErr) {
+ return ErrorResult(fmt.Sprintf("failed to inspect existing install for %q: %v", slug, statErr))
+ }
+ }
+
// Ensure skills directory exists.
- if err := os.MkdirAll(skillsDir, 0o755); err != nil {
- return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err))
+ if mkdirErr := os.MkdirAll(skillsDir, 0o755); mkdirErr != nil {
+ restorePreviousInstall()
+ return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", mkdirErr))
}
// Download and install (handles metadata, version resolution, extraction).
@@ -128,6 +174,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
"error": rmErr.Error(),
})
}
+ restorePreviousInstall()
return ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err))
}
@@ -142,11 +189,26 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
"error": rmErr.Error(),
})
}
+ restorePreviousInstall()
return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug))
}
+ if !workspaceHasValidInstalledSkill(t.workspace, dirName) {
+ rmErr := os.RemoveAll(targetDir)
+ if rmErr != nil {
+ logger.ErrorCF("tool", "Failed to remove invalid installed skill",
+ map[string]any{
+ "tool": "install_skill",
+ "target_dir": targetDir,
+ "error": rmErr.Error(),
+ })
+ }
+ restorePreviousInstall()
+ return ErrorResult(fmt.Sprintf("failed to install %q: registry archive is not a valid skill", slug))
+ }
+
// Write origin metadata.
- if err := writeOriginMeta(targetDir, registry.Name(), slug, result.Version); err != nil {
+ if err := persistInstalledSkillOriginMeta(targetDir, registry, slug, result.Version); err != nil {
logger.ErrorCF("tool", "Failed to write origin metadata",
map[string]any{
"tool": "install_skill",
@@ -156,7 +218,27 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
"slug": slug,
"version": result.Version,
})
- _ = err
+ rmErr := os.RemoveAll(targetDir)
+ if rmErr != nil {
+ logger.ErrorCF("tool", "Failed to roll back install after metadata write failure",
+ map[string]any{
+ "tool": "install_skill",
+ "target_dir": targetDir,
+ "error": rmErr.Error(),
+ })
+ }
+ restorePreviousInstall()
+ return ErrorResult(fmt.Sprintf("failed to persist skill metadata for %q: %v", slug, err))
+ }
+ if backupDir != "" {
+ if rmErr := os.RemoveAll(backupDir); rmErr != nil {
+ logger.ErrorCF("tool", "Failed to remove previous install backup after successful reinstall",
+ map[string]any{
+ "tool": "install_skill",
+ "backup_dir": backupDir,
+ "error": rmErr.Error(),
+ })
+ }
}
// Build result with moderation warning if suspicious.
@@ -178,17 +260,27 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
// originMeta tracks which registry a skill was installed from.
type originMeta struct {
Version int `json:"version"`
+ OriginKind string `json:"origin_kind,omitempty"`
Registry string `json:"registry"`
Slug string `json:"slug"`
+ RegistryURL string `json:"registry_url,omitempty"`
InstalledVersion string `json:"installed_version"`
InstalledAt int64 `json:"installed_at"`
}
-func writeOriginMeta(targetDir, registryName, slug, version string) error {
+func writeOriginMeta(targetDir string, registry skills.SkillRegistry, slug, version string) error {
+ normalizedSlug, registryURL := skills.BuildInstallMetadataForRegistryInstance(registry, slug, version)
+ registryName := ""
+ if registry != nil {
+ registryName = registry.Name()
+ }
+
meta := originMeta{
Version: 1,
+ OriginKind: "third_party",
Registry: registryName,
- Slug: slug,
+ Slug: normalizedSlug,
+ RegistryURL: registryURL,
InstalledVersion: version,
InstalledAt: time.Now().UnixMilli(),
}
@@ -201,3 +293,16 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error {
// Use unified atomic write utility with explicit sync for flash storage reliability.
return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
}
+
+func workspaceHasValidInstalledSkill(workspace, directory string) bool {
+ loader := skills.NewSkillsLoader(workspace, "", "")
+ for _, skill := range loader.ListSkills() {
+ if skill.Source != "workspace" {
+ continue
+ }
+ if filepath.Base(filepath.Dir(skill.Path)) == directory {
+ return true
+ }
+ }
+ return false
+}
diff --git a/pkg/tools/integration/skills_install_test.go b/pkg/tools/integration/skills_install_test.go
new file mode 100644
index 000000000..01d2fd2bc
--- /dev/null
+++ b/pkg/tools/integration/skills_install_test.go
@@ -0,0 +1,423 @@
+package integrationtools
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
+)
+
+type mockInstallRegistry struct{}
+
+const validSkillMarkdown = "---\nname: pr-review\ndescription: Review pull requests\n---\n# PR Review\n"
+
+func (m *mockInstallRegistry) Name() string { return "clawhub" }
+
+func (m *mockInstallRegistry) ResolveInstallDirName(target string) (string, error) {
+ return target, nil
+}
+
+func (m *mockInstallRegistry) SkillURL(slug, _ string) string { return slug }
+
+func (m *mockInstallRegistry) Search(context.Context, string, int) ([]skills.SearchResult, error) {
+ return nil, nil
+}
+
+func (m *mockInstallRegistry) GetSkillMeta(context.Context, string) (*skills.SkillMeta, error) {
+ return nil, nil
+}
+
+func (m *mockInstallRegistry) DownloadAndInstall(
+ _ context.Context,
+ _ string,
+ _ string,
+ targetDir string,
+) (*skills.InstallResult, error) {
+ if err := os.MkdirAll(targetDir, 0o755); err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(filepath.Join(targetDir, "SKILL.md"), []byte(validSkillMarkdown), 0o600); err != nil {
+ return nil, err
+ }
+ return &skills.InstallResult{Version: "test"}, nil
+}
+
+type mockGitHubInstallRegistry struct{}
+
+func (m *mockGitHubInstallRegistry) Name() string { return "github" }
+
+func (m *mockGitHubInstallRegistry) ResolveInstallDirName(target string) (string, error) {
+ return "pr-review", nil
+}
+
+func (m *mockGitHubInstallRegistry) SkillURL(slug, _ string) string { return slug }
+
+func (m *mockGitHubInstallRegistry) Search(context.Context, string, int) ([]skills.SearchResult, error) {
+ return nil, nil
+}
+
+func (m *mockGitHubInstallRegistry) GetSkillMeta(context.Context, string) (*skills.SkillMeta, error) {
+ return nil, nil
+}
+
+func (m *mockGitHubInstallRegistry) DownloadAndInstall(
+ _ context.Context,
+ _ string,
+ _ string,
+ targetDir string,
+) (*skills.InstallResult, error) {
+ if err := os.MkdirAll(targetDir, 0o755); err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(filepath.Join(targetDir, "SKILL.md"), []byte(validSkillMarkdown), 0o600); err != nil {
+ return nil, err
+ }
+ return &skills.InstallResult{Version: "main"}, nil
+}
+
+type stubGitHubInstallRegistry struct {
+ *skills.GitHubRegistry
+}
+
+func (m *stubGitHubInstallRegistry) DownloadAndInstall(
+ _ context.Context,
+ _ string,
+ _ string,
+ targetDir string,
+) (*skills.InstallResult, error) {
+ if err := os.MkdirAll(targetDir, 0o755); err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(filepath.Join(targetDir, "SKILL.md"), []byte(validSkillMarkdown), 0o600); err != nil {
+ return nil, err
+ }
+ return &skills.InstallResult{Version: "main"}, nil
+}
+
+type mockInvalidInstallRegistry struct{}
+
+type mockFailingInstallRegistry struct{}
+
+func (m *mockInvalidInstallRegistry) Name() string { return "clawhub" }
+
+func (m *mockInvalidInstallRegistry) ResolveInstallDirName(target string) (string, error) {
+ return target, nil
+}
+
+func (m *mockInvalidInstallRegistry) SkillURL(slug, _ string) string { return slug }
+
+func (m *mockInvalidInstallRegistry) Search(context.Context, string, int) ([]skills.SearchResult, error) {
+ return nil, nil
+}
+
+func (m *mockInvalidInstallRegistry) GetSkillMeta(context.Context, string) (*skills.SkillMeta, error) {
+ return nil, nil
+}
+
+func (m *mockInvalidInstallRegistry) DownloadAndInstall(
+ _ context.Context,
+ _ string,
+ _ string,
+ targetDir string,
+) (*skills.InstallResult, error) {
+ if err := os.MkdirAll(targetDir, 0o755); err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(
+ filepath.Join(targetDir, "SKILL.md"),
+ []byte("---\nname: bad_skill\ndescription: invalid name\n---\n# Invalid\n"),
+ 0o600,
+ ); err != nil {
+ return nil, err
+ }
+ return &skills.InstallResult{Version: "test"}, nil
+}
+
+func (m *mockFailingInstallRegistry) Name() string { return "clawhub" }
+
+func (m *mockFailingInstallRegistry) ResolveInstallDirName(target string) (string, error) {
+ return target, nil
+}
+
+func (m *mockFailingInstallRegistry) SkillURL(slug, _ string) string { return slug }
+
+func (m *mockFailingInstallRegistry) Search(context.Context, string, int) ([]skills.SearchResult, error) {
+ return nil, nil
+}
+
+func (m *mockFailingInstallRegistry) GetSkillMeta(context.Context, string) (*skills.SkillMeta, error) {
+ return nil, nil
+}
+
+func (m *mockFailingInstallRegistry) DownloadAndInstall(
+ _ context.Context,
+ _ string,
+ _ string,
+ _ string,
+) (*skills.InstallResult, error) {
+ return nil, assert.AnError
+}
+
+func TestInstallSkillToolName(t *testing.T) {
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
+ assert.Equal(t, "install_skill", tool.Name())
+}
+
+func TestInstallSkillToolMissingSlug(t *testing.T) {
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
+ result := tool.Execute(context.Background(), map[string]any{})
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string")
+}
+
+func TestInstallSkillToolEmptySlug(t *testing.T) {
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": " ",
+ })
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string")
+}
+
+func TestInstallSkillToolUnsafeSlug(t *testing.T) {
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(skills.NewClawHubRegistry(skills.ClawHubConfig{Enabled: true}))
+ tool := NewInstallSkillTool(registryMgr, t.TempDir())
+
+ cases := []string{
+ "../etc/passwd",
+ "path/traversal",
+ "path\\traversal",
+ }
+
+ for _, slug := range cases {
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": slug,
+ "registry": "clawhub",
+ })
+ assert.True(t, result.IsError, "slug %q should be rejected", slug)
+ assert.Contains(t, result.ForLLM, "invalid slug")
+ }
+}
+
+func TestInstallSkillToolAlreadyExists(t *testing.T) {
+ workspace := t.TempDir()
+ skillDir := filepath.Join(workspace, "skills", "existing-skill")
+ require.NoError(t, os.MkdirAll(skillDir, 0o755))
+
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(&mockInstallRegistry{})
+ tool := NewInstallSkillTool(registryMgr, workspace)
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": "existing-skill",
+ "registry": "clawhub",
+ })
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "already installed")
+}
+
+func TestInstallSkillToolRegistryNotFound(t *testing.T) {
+ workspace := t.TempDir()
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": "some-skill",
+ "registry": "nonexistent",
+ })
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "registry")
+ assert.Contains(t, result.ForLLM, "not found")
+}
+
+func TestInstallSkillToolParameters(t *testing.T) {
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
+ params := tool.Parameters()
+
+ props, ok := params["properties"].(map[string]any)
+ assert.True(t, ok)
+ assert.Contains(t, props, "slug")
+ assert.Contains(t, props, "version")
+ assert.Contains(t, props, "registry")
+ assert.Contains(t, props, "force")
+
+ required, ok := params["required"].([]string)
+ assert.True(t, ok)
+ assert.Contains(t, required, "slug")
+ assert.NotContains(t, required, "registry")
+}
+
+func TestInstallSkillToolMissingRegistry(t *testing.T) {
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(&mockGitHubInstallRegistry{})
+ tool := NewInstallSkillTool(registryMgr, t.TempDir())
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": "some-skill",
+ })
+ assert.False(t, result.IsError)
+ assert.Contains(t, result.ForLLM, `Successfully installed skill`)
+}
+
+func TestInstallSkillToolAllowsGitHubURLSlug(t *testing.T) {
+ registry := skills.GitHubRegistryConfig{Enabled: true, BaseURL: "https://github.com"}.BuildRegistry()
+ githubRegistry, ok := registry.(*skills.GitHubRegistry)
+ require.True(t, ok)
+
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(&stubGitHubInstallRegistry{GitHubRegistry: githubRegistry})
+ workspace := t.TempDir()
+ tool := NewInstallSkillTool(registryMgr, workspace)
+
+ slug := "https://github.com/synthetic-lab/octofriend/tree/main/.agents/skills/pr-review"
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": slug,
+ "registry": "github",
+ })
+
+ assert.False(t, result.IsError)
+ assert.Contains(t, result.ForLLM, `Successfully installed skill`)
+
+ data, err := os.ReadFile(filepath.Join(workspace, "skills", "pr-review", ".skill-origin.json"))
+ require.NoError(t, err)
+
+ var meta originMeta
+ require.NoError(t, json.Unmarshal(data, &meta))
+ assert.Equal(t, "third_party", meta.OriginKind)
+ assert.Equal(t, "github", meta.Registry)
+ assert.Equal(t, "synthetic-lab/octofriend/.agents/skills/pr-review", meta.Slug)
+ assert.Equal(t, slug, meta.RegistryURL)
+ assert.Equal(t, "main", meta.InstalledVersion)
+ assert.NotZero(t, meta.InstalledAt)
+}
+
+func TestInstallSkillToolPreservesGitHubSourceURLWithEnterpriseRegistry(t *testing.T) {
+ registry := skills.GitHubRegistryConfig{Enabled: true, BaseURL: "https://ghe.example.com/git"}.BuildRegistry()
+ githubRegistry, ok := registry.(*skills.GitHubRegistry)
+ require.True(t, ok)
+
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(&stubGitHubInstallRegistry{GitHubRegistry: githubRegistry})
+ workspace := t.TempDir()
+ tool := NewInstallSkillTool(registryMgr, workspace)
+
+ slug := "https://github.com/synthetic-lab/octofriend/tree/main/.agents/skills/pr-review"
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": slug,
+ "registry": "github",
+ })
+
+ assert.False(t, result.IsError)
+
+ data, err := os.ReadFile(filepath.Join(workspace, "skills", "pr-review", ".skill-origin.json"))
+ require.NoError(t, err)
+
+ var meta originMeta
+ require.NoError(t, json.Unmarshal(data, &meta))
+ assert.Equal(t, "synthetic-lab/octofriend/.agents/skills/pr-review", meta.Slug)
+ assert.Equal(t, slug, meta.RegistryURL)
+ assert.Equal(t, "main", meta.InstalledVersion)
+}
+
+func TestInstallSkillToolRejectsInvalidInstalledSkill(t *testing.T) {
+ workspace := t.TempDir()
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(&mockInvalidInstallRegistry{})
+ tool := NewInstallSkillTool(registryMgr, workspace)
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": "broken-skill",
+ "registry": "clawhub",
+ })
+
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "not a valid skill")
+ _, err := os.Stat(filepath.Join(workspace, "skills", "broken-skill"))
+ assert.True(t, os.IsNotExist(err))
+}
+
+func TestInstallSkillToolRollsBackOnOriginMetadataWriteFailure(t *testing.T) {
+ workspace := t.TempDir()
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(&mockInstallRegistry{})
+ tool := NewInstallSkillTool(registryMgr, workspace)
+
+ previousPersist := persistInstalledSkillOriginMeta
+ persistInstalledSkillOriginMeta = func(string, skills.SkillRegistry, string, string) error {
+ return assert.AnError
+ }
+ defer func() {
+ persistInstalledSkillOriginMeta = previousPersist
+ }()
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": "rollback-skill",
+ "registry": "clawhub",
+ })
+
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "failed to persist skill metadata")
+ _, err := os.Stat(filepath.Join(workspace, "skills", "rollback-skill"))
+ assert.True(t, os.IsNotExist(err))
+}
+
+func TestInstallSkillToolForceReinstallRestoresPreviousSkillAfterDownloadFailure(t *testing.T) {
+ workspace := t.TempDir()
+ skillDir := filepath.Join(workspace, "skills", "existing-skill")
+ require.NoError(t, os.MkdirAll(skillDir, 0o755))
+ oldContent := []byte("---\nname: existing-skill\ndescription: Existing skill\n---\n# Existing\n")
+ require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), oldContent, 0o600))
+
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(&mockFailingInstallRegistry{})
+ tool := NewInstallSkillTool(registryMgr, workspace)
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": "existing-skill",
+ "registry": "clawhub",
+ "force": true,
+ })
+
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "failed to install")
+
+ gotContent, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md"))
+ require.NoError(t, err)
+ assert.Equal(t, oldContent, gotContent)
+}
+
+func TestInstallSkillToolForceReinstallRestoresPreviousSkillAfterMetadataFailure(t *testing.T) {
+ workspace := t.TempDir()
+ skillDir := filepath.Join(workspace, "skills", "existing-skill")
+ require.NoError(t, os.MkdirAll(skillDir, 0o755))
+ oldContent := []byte("---\nname: existing-skill\ndescription: Existing skill\n---\n# Existing\n")
+ require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), oldContent, 0o600))
+
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(&mockInstallRegistry{})
+ tool := NewInstallSkillTool(registryMgr, workspace)
+
+ previousPersist := persistInstalledSkillOriginMeta
+ persistInstalledSkillOriginMeta = func(string, skills.SkillRegistry, string, string) error {
+ return assert.AnError
+ }
+ defer func() {
+ persistInstalledSkillOriginMeta = previousPersist
+ }()
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": "existing-skill",
+ "registry": "clawhub",
+ "force": true,
+ })
+
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "failed to persist skill metadata")
+
+ gotContent, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md"))
+ require.NoError(t, err)
+ assert.Equal(t, oldContent, gotContent)
+}
diff --git a/pkg/tools/skills_search.go b/pkg/tools/integration/skills_search.go
similarity index 99%
rename from pkg/tools/skills_search.go
rename to pkg/tools/integration/skills_search.go
index 2b6cffd38..f080aba95 100644
--- a/pkg/tools/skills_search.go
+++ b/pkg/tools/integration/skills_search.go
@@ -1,4 +1,4 @@
-package tools
+package integrationtools
import (
"context"
diff --git a/pkg/tools/skills_search_test.go b/pkg/tools/integration/skills_search_test.go
similarity index 99%
rename from pkg/tools/skills_search_test.go
rename to pkg/tools/integration/skills_search_test.go
index 0e5387cf5..fcce48b49 100644
--- a/pkg/tools/skills_search_test.go
+++ b/pkg/tools/integration/skills_search_test.go
@@ -1,4 +1,4 @@
-package tools
+package integrationtools
import (
"context"
diff --git a/pkg/tools/integration/tts_send.go b/pkg/tools/integration/tts_send.go
new file mode 100644
index 000000000..6c9135624
--- /dev/null
+++ b/pkg/tools/integration/tts_send.go
@@ -0,0 +1,82 @@
+package integrationtools
+
+import (
+ "context"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/audio/tts"
+ "github.com/sipeed/picoclaw/pkg/media"
+)
+
+type SendTTSTool struct {
+ provider tts.TTSProvider
+ mediaStore media.MediaStore
+}
+
+func NewSendTTSTool(provider tts.TTSProvider, store media.MediaStore) *SendTTSTool {
+ return &SendTTSTool{
+ provider: provider,
+ mediaStore: store,
+ }
+}
+
+func (t *SendTTSTool) Name() string { return "send_tts" }
+
+func (t *SendTTSTool) Description() string {
+ return "Synthesize speech from text and send it as an audio file to the user."
+}
+
+func (t *SendTTSTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "text": map[string]any{
+ "type": "string",
+ "description": "The text to synthesize into speech. NOTE: Reply in a highly concise, conversational, oral style suitable for text-to-speech. Do not use markdown, emojis, asterisks, or code blocks. Speak naturally.",
+ },
+ "filename": map[string]any{
+ "type": "string",
+ "description": "Optional filename for the audio file (e.g., response.ogg).",
+ },
+ },
+ "required": []string{"text"},
+ }
+}
+
+func (t *SendTTSTool) SetMediaStore(store media.MediaStore) {
+ t.mediaStore = store
+}
+
+func (t *SendTTSTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ text, _ := args["text"].(string)
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return ErrorResult("text is required")
+ }
+
+ channel := ToolChannel(ctx)
+ chatID := ToolChatID(ctx)
+ filename, _ := args["filename"].(string)
+
+ ref, err := tts.SynthesizeAndStore(
+ ctx,
+ t.provider,
+ t.mediaStore,
+ text,
+ filename,
+ channel,
+ chatID,
+ )
+ if err != nil {
+ return ErrorResult(err.Error()).WithError(err)
+ }
+
+ // Return with ForUser set to original text, Media containing the audio ref,
+ // and mark as ResponseHandled so the audio is sent immediately without LLM intervention.
+ return &ToolResult{
+ ForLLM: "TTS audio sent",
+ ForUser: text,
+ Media: []string{ref},
+ ResponseHandled: true,
+ }
+}
diff --git a/pkg/tools/web.go b/pkg/tools/integration/web.go
similarity index 72%
rename from pkg/tools/web.go
rename to pkg/tools/integration/web.go
index 342f7458b..75821e40d 100644
--- a/pkg/tools/web.go
+++ b/pkg/tools/integration/web.go
@@ -1,4 +1,4 @@
-package tools
+package integrationtools
import (
"bytes"
@@ -15,6 +15,7 @@ import (
"strings"
"sync/atomic"
"time"
+ "unicode"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -23,6 +24,7 @@ import (
const (
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+ sogouUserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1"
userAgentHonest = "picoclaw/%s (+https://github.com/sipeed/picoclaw; AI assistant bot)"
// HTTP client timeouts for web tool providers.
@@ -46,7 +48,14 @@ var (
reDDGLink = regexp.MustCompile(
`]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)`,
)
- reDDGSnippet = regexp.MustCompile(`([\s\S]*?)`)
+ reDDGSnippet = regexp.MustCompile(
+ `([\s\S]*?)`,
+ )
+ reSogouTitle = regexp.MustCompile(
+ `]*id="sogou_vr_\d+_\d+"[^>]*>\s*(.*?)\s*`,
+ )
+ reSogouSnippet = regexp.MustCompile(`\s*(.*?)\s*`)
+ reSogouRealURL = regexp.MustCompile(`url=([^&]+)`)
)
type APIKeyPool struct {
@@ -91,6 +100,39 @@ type SearchProvider interface {
Search(ctx context.Context, query string, count int, rangeCode string) (string, error)
}
+type SearchResultItem struct {
+ Title string
+ URL string
+ Snippet string
+}
+
+func extractSogouURL(href string) string {
+ match := reSogouRealURL.FindStringSubmatch(href)
+ if len(match) < 2 {
+ return ""
+ }
+ decoded, err := url.QueryUnescape(match[1])
+ if err != nil {
+ return ""
+ }
+ return decoded
+}
+
+func applySogouRangeHint(query string, rangeCode string) string {
+ switch rangeCode {
+ case "d":
+ return query + " 最近一天"
+ case "w":
+ return query + " 最近一周"
+ case "m":
+ return query + " 最近一个月"
+ case "y":
+ return query + " 最近一年"
+ default:
+ return query
+ }
+}
+
func normalizeSearchRange(raw string) (string, error) {
rangeCode := strings.ToLower(strings.TrimSpace(raw))
switch rangeCode {
@@ -218,6 +260,10 @@ func (p *BraveSearchProvider) Search(
count int,
rangeCode string,
) (string, error) {
+ if p.keyPool == nil || len(p.keyPool.keys) == 0 {
+ return "", errors.New("no API key provided")
+ }
+
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
url.QueryEscape(query), count)
if freshness := mapBraveFreshness(rangeCode); freshness != "" {
@@ -317,6 +363,10 @@ func (p *TavilySearchProvider) Search(
count int,
rangeCode string,
) (string, error) {
+ if p.keyPool == nil || len(p.keyPool.keys) == 0 {
+ return "", errors.New("no API key provided")
+ }
+
searchURL := p.baseURL
if searchURL == "" {
searchURL = "https://api.tavily.com/search"
@@ -417,6 +467,104 @@ func (p *TavilySearchProvider) Search(
return "", fmt.Errorf("all api keys failed, last error: %w", lastErr)
}
+type SogouSearchProvider struct {
+ proxy string
+ client *http.Client
+}
+
+func (p *SogouSearchProvider) Search(
+ ctx context.Context,
+ query string,
+ count int,
+ rangeCode string,
+) (string, error) {
+ const sogouWAPURL = "https://wap.sogou.com/web/searchList.jsp"
+
+ results := make([]SearchResultItem, 0, count)
+ seenURLs := make(map[string]bool)
+ maxPages := min(3, (count+1)/2+1)
+
+ for page := 1; page <= maxPages && len(results) < count; page++ {
+ params := url.Values{}
+ params.Set("keyword", applySogouRangeHint(query, rangeCode))
+ params.Set("v", "5")
+ params.Set("p", fmt.Sprintf("%d", page))
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, sogouWAPURL+"?"+params.Encode(), nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("User-Agent", sogouUserAgent)
+
+ resp, err := p.client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("request failed: %w", err)
+ }
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ resp.Body.Close()
+ if err != nil {
+ return "", fmt.Errorf("failed to read response: %w", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("Sogou returned status %d", resp.StatusCode)
+ }
+
+ html := string(body)
+ if len(html) < 200 {
+ break
+ }
+
+ matches := reSogouTitle.FindAllStringSubmatch(html, -1)
+ for _, match := range matches {
+ if len(match) < 3 {
+ continue
+ }
+
+ title := stripTags(match[2])
+ link := extractSogouURL(match[1])
+ if title == "" || link == "" || seenURLs[link] {
+ continue
+ }
+ seenURLs[link] = true
+
+ start := strings.Index(html, match[0])
+ snippet := ""
+ if start >= 0 {
+ after := html[start+len(match[0]):]
+ if len(after) > 2000 {
+ after = after[:2000]
+ }
+ if snippetMatch := reSogouSnippet.FindStringSubmatch(after); len(snippetMatch) > 1 {
+ snippet = stripTags(snippetMatch[1])
+ }
+ }
+
+ results = append(results, SearchResultItem{
+ Title: title,
+ URL: link,
+ Snippet: snippet,
+ })
+ if len(results) >= count {
+ break
+ }
+ }
+ }
+
+ if len(results) == 0 {
+ return fmt.Sprintf("No results for: %s", query), nil
+ }
+
+ lines := []string{fmt.Sprintf("Results for: %s (via Sogou)", query)}
+ for i, item := range results {
+ lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
+ if item.Snippet != "" {
+ lines = append(lines, fmt.Sprintf(" %s", item.Snippet))
+ }
+ }
+ return strings.Join(lines, "\n"), nil
+}
+
type DuckDuckGoSearchProvider struct {
proxy string
client *http.Client
@@ -532,6 +680,10 @@ func (p *PerplexitySearchProvider) Search(
count int,
rangeCode string,
) (string, error) {
+ if p.keyPool == nil || len(p.keyPool.keys) == 0 {
+ return "", errors.New("no API key provided")
+ }
+
searchURL := "https://api.perplexity.ai/chat/completions"
var lastErr error
@@ -637,6 +789,8 @@ func (p *PerplexitySearchProvider) Search(
type SearXNGSearchProvider struct {
baseURL string
+ proxy string
+ client *http.Client
}
func (p *SearXNGSearchProvider) Search(
@@ -645,6 +799,10 @@ func (p *SearXNGSearchProvider) Search(
count int,
rangeCode string,
) (string, error) {
+ if p.baseURL == "" {
+ return "", errors.New("no SearXNG URL provided")
+ }
+
searchURL := fmt.Sprintf("%s/search?q=%s&format=json&categories=general",
strings.TrimSuffix(p.baseURL, "/"),
url.QueryEscape(query))
@@ -657,7 +815,10 @@ func (p *SearXNGSearchProvider) Search(
return "", fmt.Errorf("failed to create request: %w", err)
}
- client := &http.Client{Timeout: 10 * time.Second}
+ client := p.client
+ if client == nil {
+ client = &http.Client{Timeout: searchTimeout}
+ }
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
@@ -719,6 +880,10 @@ func (p *GLMSearchProvider) Search(
count int,
rangeCode string,
) (string, error) {
+ if p.apiKey == "" {
+ return "", errors.New("no API key provided")
+ }
+
searchURL := p.baseURL
if searchURL == "" {
searchURL = "https://open.bigmodel.cn/api/paas/v4/web_search"
@@ -808,6 +973,10 @@ func (p *BaiduSearchProvider) Search(
count int,
rangeCode string,
) (string, error) {
+ if p.apiKey == "" {
+ return "", errors.New("no API key provided")
+ }
+
searchURL := p.baseURL
if searchURL == "" {
searchURL = "https://qianfan.baidubce.com/v2/ai_search/web_search"
@@ -885,11 +1054,13 @@ func (p *BaiduSearchProvider) Search(
}
type WebSearchTool struct {
- provider SearchProvider
- maxResults int
+ provider SearchProvider
+ maxResults int
+ providerResolver func(query string) (SearchProvider, int)
}
type WebSearchToolOptions struct {
+ Provider string
BraveAPIKeys []string
BraveMaxResults int
BraveEnabled bool
@@ -897,6 +1068,8 @@ type WebSearchToolOptions struct {
TavilyBaseURL string
TavilyMaxResults int
TavilyEnabled bool
+ SogouMaxResults int
+ SogouEnabled bool
DuckDuckGoMaxResults int
DuckDuckGoEnabled bool
PerplexityAPIKeys []string
@@ -917,100 +1090,370 @@ type WebSearchToolOptions struct {
Proxy string
}
-func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
- var provider SearchProvider
- maxResults := 10
- // Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > Baidu Search > GLM Search
- if opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 {
+func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions {
+ return WebSearchToolOptions{
+ Provider: cfg.Tools.Web.Provider,
+ BraveAPIKeys: cfg.Tools.Web.Brave.APIKeys.Values(),
+ BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
+ BraveEnabled: cfg.Tools.Web.Brave.Enabled,
+ TavilyAPIKeys: cfg.Tools.Web.Tavily.APIKeys.Values(),
+ TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
+ TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
+ TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
+ SogouMaxResults: cfg.Tools.Web.Sogou.MaxResults,
+ SogouEnabled: cfg.Tools.Web.Sogou.Enabled,
+ DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
+ DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
+ PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(),
+ PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
+ PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
+ SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
+ SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults,
+ SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled,
+ GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey.String(),
+ GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
+ GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
+ GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
+ GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled,
+ BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey.String(),
+ BaiduSearchBaseURL: cfg.Tools.Web.BaiduSearch.BaseURL,
+ BaiduSearchMaxResults: cfg.Tools.Web.BaiduSearch.MaxResults,
+ BaiduSearchEnabled: cfg.Tools.Web.BaiduSearch.Enabled,
+ Proxy: cfg.Tools.Web.Proxy,
+ }
+}
+
+func WebSearchProviderReady(opts WebSearchToolOptions, name string) bool {
+ return opts.providerReady(name)
+}
+
+func ResolveWebSearchProviderName(opts WebSearchToolOptions, query string) (string, error) {
+ return opts.resolveProviderName(query)
+}
+
+var (
+ knownWebSearchProviders = []string{
+ "sogou",
+ "duckduckgo",
+ "brave",
+ "tavily",
+ "perplexity",
+ "searxng",
+ "glm_search",
+ "baidu_search",
+ }
+ autoPrimaryWebSearchProviders = []string{"perplexity", "brave", "searxng", "tavily"}
+ autoFallbackWebSearchProviders = []string{"baidu_search", "glm_search"}
+)
+
+func isKnownWebSearchProvider(name string) bool {
+ name = strings.ToLower(strings.TrimSpace(name))
+ for _, known := range knownWebSearchProviders {
+ if name == known {
+ return true
+ }
+ }
+ return false
+}
+
+func (opts WebSearchToolOptions) providerReady(name string) bool {
+ switch strings.ToLower(strings.TrimSpace(name)) {
+ case "sogou":
+ return opts.SogouEnabled
+ case "duckduckgo":
+ return opts.DuckDuckGoEnabled
+ case "brave":
+ return opts.BraveEnabled && len(opts.BraveAPIKeys) > 0
+ case "tavily":
+ return opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0
+ case "perplexity":
+ return opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0
+ case "searxng":
+ return opts.SearXNGEnabled && strings.TrimSpace(opts.SearXNGBaseURL) != ""
+ case "glm_search":
+ return opts.GLMSearchEnabled && strings.TrimSpace(opts.GLMSearchAPIKey) != ""
+ case "baidu_search":
+ return opts.BaiduSearchEnabled && strings.TrimSpace(opts.BaiduSearchAPIKey) != ""
+ default:
+ return false
+ }
+}
+
+func (opts WebSearchToolOptions) normalizedProviderName() string {
+ providerName := strings.ToLower(strings.TrimSpace(opts.Provider))
+ if providerName != "" && providerName != "auto" && !isKnownWebSearchProvider(providerName) {
+ // Tolerate stale or manually edited config values at runtime by
+ // treating them as "auto" and falling back to the next ready provider.
+ return "auto"
+ }
+ return providerName
+}
+
+func (opts WebSearchToolOptions) resolveProviderName(query string) (string, error) {
+ providerName := opts.normalizedProviderName()
+ if providerName != "" && providerName != "auto" && opts.providerReady(providerName) {
+ return providerName, nil
+ }
+
+ for _, name := range autoPrimaryWebSearchProviders {
+ if opts.providerReady(name) {
+ return name, nil
+ }
+ }
+
+ sogouReady := opts.providerReady("sogou")
+ duckReady := opts.providerReady("duckduckgo")
+ if sogouReady && duckReady {
+ if prefersDuckDuckGoQuery(query) {
+ return "duckduckgo", nil
+ }
+ return "sogou", nil
+ }
+ if sogouReady {
+ return "sogou", nil
+ }
+ if duckReady {
+ return "duckduckgo", nil
+ }
+
+ for _, name := range autoFallbackWebSearchProviders {
+ if opts.providerReady(name) {
+ return name, nil
+ }
+ }
+
+ return "", nil
+}
+
+func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, int, error) {
+ switch strings.ToLower(strings.TrimSpace(name)) {
+ case "", "auto":
+ return nil, 0, nil
+ case "sogou":
+ if !opts.providerReady("sogou") {
+ return nil, 0, nil
+ }
+ client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
+ if err != nil {
+ return nil, 0, fmt.Errorf("failed to create HTTP client for Sogou: %w", err)
+ }
+ maxResults := 10
+ if opts.SogouMaxResults > 0 {
+ maxResults = min(opts.SogouMaxResults, 10)
+ }
+ return &SogouSearchProvider{
+ proxy: opts.Proxy,
+ client: client,
+ }, maxResults, nil
+ case "perplexity":
+ if !opts.providerReady("perplexity") {
+ return nil, 0, nil
+ }
client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout)
if err != nil {
- return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err)
- }
- provider = &PerplexitySearchProvider{
- keyPool: NewAPIKeyPool(opts.PerplexityAPIKeys),
- proxy: opts.Proxy,
- client: client,
+ return nil, 0, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err)
}
+ maxResults := 10
if opts.PerplexityMaxResults > 0 {
maxResults = min(opts.PerplexityMaxResults, 10)
}
- } else if opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 {
+ return &PerplexitySearchProvider{
+ keyPool: NewAPIKeyPool(opts.PerplexityAPIKeys),
+ proxy: opts.Proxy,
+ client: client,
+ }, maxResults, nil
+ case "brave":
+ if !opts.providerReady("brave") {
+ return nil, 0, nil
+ }
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
if err != nil {
- return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err)
+ return nil, 0, fmt.Errorf("failed to create HTTP client for Brave: %w", err)
}
- provider = &BraveSearchProvider{keyPool: NewAPIKeyPool(opts.BraveAPIKeys), proxy: opts.Proxy, client: client}
+ maxResults := 10
if opts.BraveMaxResults > 0 {
maxResults = min(opts.BraveMaxResults, 10)
}
- } else if opts.SearXNGEnabled && opts.SearXNGBaseURL != "" {
- provider = &SearXNGSearchProvider{baseURL: opts.SearXNGBaseURL}
+ return &BraveSearchProvider{
+ keyPool: NewAPIKeyPool(opts.BraveAPIKeys),
+ proxy: opts.Proxy,
+ client: client,
+ }, maxResults, nil
+ case "searxng":
+ if !opts.providerReady("searxng") {
+ return nil, 0, nil
+ }
+ client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
+ if err != nil {
+ return nil, 0, fmt.Errorf("failed to create HTTP client for SearXNG: %w", err)
+ }
+ maxResults := 10
if opts.SearXNGMaxResults > 0 {
maxResults = min(opts.SearXNGMaxResults, 10)
}
- } else if opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 {
+ return &SearXNGSearchProvider{
+ baseURL: opts.SearXNGBaseURL,
+ proxy: opts.Proxy,
+ client: client,
+ }, maxResults, nil
+ case "tavily":
+ if !opts.providerReady("tavily") {
+ return nil, 0, nil
+ }
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
if err != nil {
- return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err)
+ return nil, 0, fmt.Errorf("failed to create HTTP client for Tavily: %w", err)
}
- provider = &TavilySearchProvider{
+ maxResults := 10
+ if opts.TavilyMaxResults > 0 {
+ maxResults = min(opts.TavilyMaxResults, 10)
+ }
+ return &TavilySearchProvider{
keyPool: NewAPIKeyPool(opts.TavilyAPIKeys),
baseURL: opts.TavilyBaseURL,
proxy: opts.Proxy,
client: client,
+ }, maxResults, nil
+ case "duckduckgo":
+ if !opts.providerReady("duckduckgo") {
+ return nil, 0, nil
}
- if opts.TavilyMaxResults > 0 {
- maxResults = min(opts.TavilyMaxResults, 10)
- }
- } else if opts.DuckDuckGoEnabled {
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
if err != nil {
- return nil, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err)
+ return nil, 0, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err)
}
- provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy, client: client}
+ maxResults := 10
if opts.DuckDuckGoMaxResults > 0 {
maxResults = min(opts.DuckDuckGoMaxResults, 10)
}
- } else if opts.BaiduSearchEnabled && opts.BaiduSearchAPIKey != "" {
+ return &DuckDuckGoSearchProvider{
+ proxy: opts.Proxy,
+ client: client,
+ }, maxResults, nil
+ case "baidu_search":
+ if !opts.providerReady("baidu_search") {
+ return nil, 0, nil
+ }
client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout)
if err != nil {
- return nil, fmt.Errorf("failed to create HTTP client for Baidu Search: %w", err)
+ return nil, 0, fmt.Errorf("failed to create HTTP client for Baidu Search: %w", err)
}
- provider = &BaiduSearchProvider{
+ maxResults := 10
+ if opts.BaiduSearchMaxResults > 0 {
+ maxResults = min(opts.BaiduSearchMaxResults, 10)
+ }
+ return &BaiduSearchProvider{
apiKey: opts.BaiduSearchAPIKey,
baseURL: opts.BaiduSearchBaseURL,
proxy: opts.Proxy,
client: client,
+ }, maxResults, nil
+ case "glm_search":
+ if !opts.providerReady("glm_search") {
+ return nil, 0, nil
}
- if opts.BaiduSearchMaxResults > 0 {
- maxResults = min(opts.BaiduSearchMaxResults, 10)
- }
- } else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != "" {
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
if err != nil {
- return nil, fmt.Errorf("failed to create HTTP client for GLM Search: %w", err)
+ return nil, 0, fmt.Errorf("failed to create HTTP client for GLM Search: %w", err)
}
searchEngine := opts.GLMSearchEngine
if searchEngine == "" {
searchEngine = "search_std"
}
- provider = &GLMSearchProvider{
+ maxResults := 10
+ if opts.GLMSearchMaxResults > 0 {
+ maxResults = min(opts.GLMSearchMaxResults, 10)
+ }
+ return &GLMSearchProvider{
apiKey: opts.GLMSearchAPIKey,
baseURL: opts.GLMSearchBaseURL,
searchEngine: searchEngine,
proxy: opts.Proxy,
client: client,
+ }, maxResults, nil
+ default:
+ return nil, 0, fmt.Errorf("unknown web search provider %q", name)
+ }
+}
+
+func containsHan(text string) bool {
+ for _, r := range text {
+ if unicode.Is(unicode.Han, r) {
+ return true
}
- if opts.GLMSearchMaxResults > 0 {
- maxResults = min(opts.GLMSearchMaxResults, 10)
+ }
+ return false
+}
+
+func containsLatinLetter(text string) bool {
+ for _, r := range text {
+ if unicode.IsLetter(r) && unicode.In(r, unicode.Latin) {
+ return true
}
- } else {
+ }
+ return false
+}
+
+func prefersDuckDuckGoQuery(text string) bool {
+ trimmed := strings.TrimSpace(text)
+ if trimmed == "" {
+ return false
+ }
+ if containsHan(trimmed) {
+ return false
+ }
+ if containsLatinLetter(trimmed) {
+ return true
+ }
+ return false
+}
+
+func (opts WebSearchToolOptions) buildProviderResolver() (func(query string) (SearchProvider, int), error) {
+ providersByName := make(map[string]SearchProvider, len(knownWebSearchProviders))
+ maxResultsByName := make(map[string]int, len(knownWebSearchProviders))
+
+ for _, name := range knownWebSearchProviders {
+ if !opts.providerReady(name) {
+ continue
+ }
+ provider, maxResults, err := opts.providerByName(name)
+ if err != nil {
+ return nil, err
+ }
+ if provider == nil {
+ continue
+ }
+ providersByName[name] = provider
+ maxResultsByName[name] = maxResults
+ }
+
+ return func(query string) (SearchProvider, int) {
+ name, err := opts.resolveProviderName(query)
+ if err != nil {
+ return nil, 0
+ }
+ provider, ok := providersByName[name]
+ if !ok {
+ return nil, 0
+ }
+ return provider, maxResultsByName[name]
+ }, nil
+}
+
+func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
+ resolver, err := opts.buildProviderResolver()
+ if err != nil {
+ return nil, err
+ }
+ provider, maxResults := resolver("")
+ if provider == nil {
return nil, nil
}
return &WebSearchTool{
- provider: provider,
- maxResults: maxResults,
+ provider: provider,
+ maxResults: maxResults,
+ providerResolver: resolver,
}, nil
}
@@ -1053,13 +1496,22 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolR
}
query = strings.TrimSpace(query)
- count64, err := getInt64Arg(args, "count", int64(t.maxResults))
+ provider := t.provider
+ maxResults := t.maxResults
+ if t.providerResolver != nil {
+ provider, maxResults = t.providerResolver(query)
+ }
+ if provider == nil {
+ return ErrorResult("search provider is not configured")
+ }
+
+ count64, err := getInt64Arg(args, "count", int64(maxResults))
if err != nil {
return ErrorResult(err.Error())
}
- count := t.maxResults
+ count := maxResults
if count64 > 0 && count64 <= 10 {
- count = int(count64)
+ count = min(int(count64), maxResults)
}
rangeCode, err := normalizeSearchRange("")
@@ -1077,7 +1529,7 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolR
}
}
- result, err := t.provider.Search(ctx, query, count, rangeCode)
+ result, err := provider.Search(ctx, query, count, rangeCode)
if err != nil {
return ErrorResult(fmt.Sprintf("search failed: %v", err))
}
@@ -1102,6 +1554,8 @@ type privateHostWhitelist struct {
cidrs []*net.IPNet
}
+type webFetchAllowedFirstHopHostKey struct{}
+
func NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) {
// createHTTPClient cannot fail with an empty proxy string.
return NewWebFetchToolWithConfig(maxChars, "", format, fetchLimitBytes, nil)
@@ -1153,6 +1607,7 @@ func NewWebFetchToolWithConfig(
if isObviousPrivateHost(req.URL.Hostname(), whitelist) {
return fmt.Errorf("redirect target is private or local network host")
}
+ allowConfiguredProxyFirstHop(req, client.Transport)
return nil
}
if fetchLimitBytes <= 0 {
@@ -1232,6 +1687,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
if reqErr != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", reqErr)
}
+ allowConfiguredProxyFirstHop(req, t.client.Transport)
req.Header.Set("User-Agent", ua)
resp, doErr := t.client.Do(req)
if doErr != nil {
@@ -1434,6 +1890,9 @@ func newSafeDialContext(
if host == "" {
return nil, fmt.Errorf("empty target host")
}
+ if isAllowedFirstHopHost(ctx, host) {
+ return dialer.DialContext(ctx, network, address)
+ }
if ip := net.ParseIP(host); ip != nil {
if shouldBlockPrivateIP(ip, whitelist) {
@@ -1482,6 +1941,46 @@ func newSafeDialContext(
}
}
+func allowConfiguredProxyFirstHop(req *http.Request, rt http.RoundTripper) {
+ if req == nil {
+ return
+ }
+
+ transport, ok := rt.(*http.Transport)
+ if !ok || transport.Proxy == nil {
+ return
+ }
+
+ proxyURL, err := transport.Proxy(req)
+ if err != nil || proxyURL == nil {
+ return
+ }
+
+ host := normalizeAllowedFirstHopHost(proxyURL.Hostname())
+ if host == "" {
+ return
+ }
+
+ *req = *req.WithContext(context.WithValue(
+ req.Context(),
+ webFetchAllowedFirstHopHostKey{},
+ host,
+ ))
+}
+
+func isAllowedFirstHopHost(ctx context.Context, host string) bool {
+ allowed, _ := ctx.Value(webFetchAllowedFirstHopHostKey{}).(string)
+ if allowed == "" {
+ return false
+ }
+ return allowed == normalizeAllowedFirstHopHost(host)
+}
+
+func normalizeAllowedFirstHopHost(host string) string {
+ host = strings.ToLower(strings.TrimSpace(host))
+ return strings.TrimSuffix(host, ".")
+}
+
func newPrivateHostWhitelist(entries []string) (*privateHostWhitelist, error) {
if len(entries) == 0 {
return nil, nil
diff --git a/pkg/tools/web_test.go b/pkg/tools/integration/web_test.go
similarity index 82%
rename from pkg/tools/web_test.go
rename to pkg/tools/integration/web_test.go
index de6187cfa..ba6b3da45 100644
--- a/pkg/tools/web_test.go
+++ b/pkg/tools/integration/web_test.go
@@ -1,4 +1,4 @@
-package tools
+package integrationtools
import (
"bytes"
@@ -385,14 +385,14 @@ func TestWebFetchTool_PayloadTooLarge(t *testing.T) {
}
}
-// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing
+// TestWebTool_WebSearch_NoApiKey verifies providers without required credentials are not registered.
func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKeys: nil})
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if tool != nil {
- t.Errorf("Expected nil tool when Brave API key is empty")
+ t.Fatalf("Expected nil tool when only enabled provider is missing credentials")
}
// Also nil when nothing is enabled
@@ -757,6 +757,33 @@ func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) {
}
}
+func TestWebTool_WebFetch_AllowsLoopbackProxy(t *testing.T) {
+ proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.String() != "http://example.com/proxied" {
+ t.Fatalf("proxy received URL %q, want %q", r.URL.String(), "http://example.com/proxied")
+ }
+ w.Header().Set("Content-Type", "text/plain")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("proxied content"))
+ }))
+ defer proxy.Close()
+
+ tool, err := NewWebFetchToolWithProxy(50000, proxy.URL, format, testFetchLimit, nil)
+ if err != nil {
+ t.Fatalf("Failed to create web fetch tool: %v", err)
+ }
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "url": "http://example.com/proxied",
+ })
+ if result.IsError {
+ t.Fatalf("expected success through loopback proxy, got %q", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "proxied content") {
+ t.Fatalf("expected proxied content, got %q", result.ForLLM)
+ }
+}
+
// TestWebFetch_BlocksIPv4MappedIPv6Loopback verifies ::ffff:127.0.0.1 is blocked
func TestWebFetch_BlocksIPv4MappedIPv6Loopback(t *testing.T) {
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
@@ -1082,6 +1109,40 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
}
})
+
+ t.Run("searxng", func(t *testing.T) {
+ tool, err := NewWebSearchTool(WebSearchToolOptions{
+ SearXNGEnabled: true,
+ SearXNGBaseURL: "https://searx.example.com",
+ SearXNGMaxResults: 3,
+ Proxy: "http://127.0.0.1:7890",
+ })
+ if err != nil {
+ t.Fatalf("NewWebSearchTool() error: %v", err)
+ }
+ p, ok := tool.provider.(*SearXNGSearchProvider)
+ if !ok {
+ t.Fatalf("provider type = %T, want *SearXNGSearchProvider", tool.provider)
+ }
+ if p.proxy != "http://127.0.0.1:7890" {
+ t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
+ }
+ tr, ok := p.client.Transport.(*http.Transport)
+ if !ok {
+ t.Fatalf("client.Transport type = %T, want *http.Transport", p.client.Transport)
+ }
+ req, err := http.NewRequest(http.MethodGet, "https://searx.example.com/search", nil)
+ if err != nil {
+ t.Fatalf("http.NewRequest() error: %v", err)
+ }
+ proxyURL, err := tr.Proxy(req)
+ if err != nil {
+ t.Fatalf("transport.Proxy(req) error: %v", err)
+ }
+ if proxyURL == nil || proxyURL.String() != "http://127.0.0.1:7890" {
+ t.Fatalf("proxy URL = %v, want %q", proxyURL, "http://127.0.0.1:7890")
+ }
+ })
}
// TestWebTool_TavilySearch_Success verifies successful Tavily search
@@ -1667,3 +1728,270 @@ func TestWebTool_GLMSearch_Priority(t *testing.T) {
t.Errorf("Expected GLMSearchProvider when only GLM enabled, got %T", tool2.provider)
}
}
+
+func TestWebTool_SogouSearch_Success(t *testing.T) {
+ provider := &SogouSearchProvider{
+ client: &http.Client{
+ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ rec := httptest.NewRecorder()
+ fmt.Fprint(rec, `
+Result A
+Snippet A
+Result B
+Snippet B
+`)
+ return rec.Result(), nil
+ }),
+ },
+ }
+
+ out, err := provider.Search(context.Background(), "test query", 2, "")
+ if err != nil {
+ t.Fatalf("Search() error: %v", err)
+ }
+ if !strings.Contains(out, "via Sogou") || !strings.Contains(out, "https://example.com/a") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestApplySogouRangeHint(t *testing.T) {
+ tests := []struct {
+ name string
+ query string
+ rangeCode string
+ want string
+ }{
+ {name: "empty range", query: "golang", rangeCode: "", want: "golang"},
+ {name: "day", query: "golang", rangeCode: "d", want: "golang 最近一天"},
+ {name: "week", query: "golang", rangeCode: "w", want: "golang 最近一周"},
+ {name: "month", query: "golang", rangeCode: "m", want: "golang 最近一个月"},
+ {name: "year", query: "golang", rangeCode: "y", want: "golang 最近一年"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := applySogouRangeHint(tt.query, tt.rangeCode); got != tt.want {
+ t.Fatalf("applySogouRangeHint(%q, %q) = %q, want %q", tt.query, tt.rangeCode, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestPrefersDuckDuckGoQuery(t *testing.T) {
+ tests := []struct {
+ name string
+ query string
+ want bool
+ }{
+ {name: "english words", query: "golang web search", want: true},
+ {name: "english with numbers", query: "OpenAI o3 price 2026", want: true},
+ {name: "chinese", query: "今天上海天气", want: false},
+ {name: "mixed with han", query: "golang 中文 教程", want: false},
+ {name: "numbers only", query: "2026 04 15", want: false},
+ {name: "blank", query: " ", want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := prefersDuckDuckGoQuery(tt.query); got != tt.want {
+ t.Fatalf("prefersDuckDuckGoQuery(%q) = %v, want %v", tt.query, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestPrefersDuckDuckGoQuery_DoesNotUseGlobalLanguageFallback(t *testing.T) {
+ if prefersDuckDuckGoQuery("2026 04 15") {
+ t.Fatal("numeric query should default to Sogou when no script-specific hint is present")
+ }
+}
+
+func TestWebTool_SogouPriorityAndExplicitProvider(t *testing.T) {
+ tool, err := NewWebSearchTool(WebSearchToolOptions{
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ DuckDuckGoEnabled: true,
+ DuckDuckGoMaxResults: 5,
+ })
+ if err != nil {
+ t.Fatalf("NewWebSearchTool() error: %v", err)
+ }
+ if _, ok := tool.provider.(*SogouSearchProvider); !ok {
+ t.Fatalf("expected SogouSearchProvider, got %T", tool.provider)
+ }
+
+ tool, err = NewWebSearchTool(WebSearchToolOptions{
+ Provider: "duckduckgo",
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ DuckDuckGoEnabled: true,
+ DuckDuckGoMaxResults: 5,
+ })
+ if err != nil {
+ t.Fatalf("NewWebSearchTool() error: %v", err)
+ }
+ if _, ok := tool.provider.(*DuckDuckGoSearchProvider); !ok {
+ t.Fatalf("expected DuckDuckGoSearchProvider, got %T", tool.provider)
+ }
+}
+
+func TestWebTool_AutoProviderPrefersConfiguredProvidersBeforeSogou(t *testing.T) {
+ tool, err := NewWebSearchTool(WebSearchToolOptions{
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ BraveEnabled: true,
+ BraveAPIKeys: []string{"brave-key"},
+ BraveMaxResults: 5,
+ DuckDuckGoEnabled: true,
+ DuckDuckGoMaxResults: 5,
+ })
+ if err != nil {
+ t.Fatalf("NewWebSearchTool() error: %v", err)
+ }
+ if _, ok := tool.provider.(*BraveSearchProvider); !ok {
+ t.Fatalf("expected BraveSearchProvider, got %T", tool.provider)
+ }
+}
+
+func TestWebTool_ExplicitProviderFallsBackWhenMissingCredentials(t *testing.T) {
+ tool, err := NewWebSearchTool(WebSearchToolOptions{
+ Provider: "brave",
+ BraveEnabled: true,
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ })
+ if err != nil {
+ t.Fatalf("NewWebSearchTool() error: %v", err)
+ }
+ if _, ok := tool.provider.(*SogouSearchProvider); !ok {
+ t.Fatalf("expected SogouSearchProvider after fallback, got %T", tool.provider)
+ }
+}
+
+func TestWebTool_ExplicitProviderFallsBackWhenMissingBaseURL(t *testing.T) {
+ tool, err := NewWebSearchTool(WebSearchToolOptions{
+ Provider: "searxng",
+ SearXNGEnabled: true,
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ })
+ if err != nil {
+ t.Fatalf("NewWebSearchTool() error: %v", err)
+ }
+ if _, ok := tool.provider.(*SogouSearchProvider); !ok {
+ t.Fatalf("expected SogouSearchProvider after fallback, got %T", tool.provider)
+ }
+}
+
+func TestWebTool_AutoProviderSkipsEnabledButUnreadyProviders(t *testing.T) {
+ tool, err := NewWebSearchTool(WebSearchToolOptions{
+ Provider: "auto",
+ BraveEnabled: true,
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ })
+ if err != nil {
+ t.Fatalf("NewWebSearchTool() error: %v", err)
+ }
+ if _, ok := tool.provider.(*SogouSearchProvider); !ok {
+ t.Fatalf("expected SogouSearchProvider when Brave has no API key, got %T", tool.provider)
+ }
+}
+
+func TestResolveWebSearchProviderName_FallsBackFromExplicitUnavailableProvider(t *testing.T) {
+ got, err := ResolveWebSearchProviderName(WebSearchToolOptions{
+ Provider: "brave",
+ BraveEnabled: true,
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ }, "")
+ if err != nil {
+ t.Fatalf("ResolveWebSearchProviderName() error: %v", err)
+ }
+ if got != "sogou" {
+ t.Fatalf("ResolveWebSearchProviderName() = %q, want sogou", got)
+ }
+}
+
+func TestWebTool_UnknownExplicitProviderFallsBackToAuto(t *testing.T) {
+ tool, err := NewWebSearchTool(WebSearchToolOptions{
+ Provider: "totally_unknown",
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ })
+ if err != nil {
+ t.Fatalf("NewWebSearchTool() error: %v", err)
+ }
+ if _, ok := tool.provider.(*SogouSearchProvider); !ok {
+ t.Fatalf("expected SogouSearchProvider after fallback, got %T", tool.provider)
+ }
+}
+
+func TestResolveWebSearchProviderName_FallsBackFromUnknownProvider(t *testing.T) {
+ got, err := ResolveWebSearchProviderName(WebSearchToolOptions{
+ Provider: "totally_unknown",
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ }, "")
+ if err != nil {
+ t.Fatalf("ResolveWebSearchProviderName() error: %v", err)
+ }
+ if got != "sogou" {
+ t.Fatalf("ResolveWebSearchProviderName() = %q, want sogou", got)
+ }
+}
+
+type stubSearchProvider struct {
+ result string
+ calls []string
+}
+
+func (p *stubSearchProvider) Search(
+ _ context.Context,
+ query string,
+ _ int,
+ _ string,
+) (string, error) {
+ p.calls = append(p.calls, query)
+ return p.result, nil
+}
+
+func TestWebTool_AutoProviderRoutesQueryLanguageBetweenSogouAndDuckDuckGo(t *testing.T) {
+ sogouProvider := &stubSearchProvider{result: "via sogou"}
+ duckProvider := &stubSearchProvider{result: "via duckduckgo"}
+ tool := &WebSearchTool{
+ provider: sogouProvider,
+ maxResults: 5,
+ providerResolver: func(query string) (SearchProvider, int) {
+ if prefersDuckDuckGoQuery(query) {
+ return duckProvider, 3
+ }
+ return sogouProvider, 5
+ },
+ }
+
+ enResult := tool.Execute(context.Background(), map[string]any{"query": "golang concurrency", "count": 10})
+ if enResult.IsError {
+ t.Fatalf("english Execute() returned error: %s", enResult.ForLLM)
+ }
+ if len(duckProvider.calls) != 1 || duckProvider.calls[0] != "golang concurrency" {
+ t.Fatalf("english query should use DuckDuckGo provider, calls=%v", duckProvider.calls)
+ }
+ if len(sogouProvider.calls) != 0 {
+ t.Fatalf("english query should not call Sogou provider, calls=%v", sogouProvider.calls)
+ }
+
+ zhResult := tool.Execute(context.Background(), map[string]any{"query": "今天上海天气"})
+ if zhResult.IsError {
+ t.Fatalf("chinese Execute() returned error: %s", zhResult.ForLLM)
+ }
+ if len(sogouProvider.calls) != 1 || sogouProvider.calls[0] != "今天上海天气" {
+ t.Fatalf("chinese query should use Sogou provider, calls=%v", sogouProvider.calls)
+ }
+}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return fn(req)
+}
diff --git a/pkg/tools/integration_facade.go b/pkg/tools/integration_facade.go
new file mode 100644
index 000000000..193ecd6f5
--- /dev/null
+++ b/pkg/tools/integration_facade.go
@@ -0,0 +1,106 @@
+package tools
+
+import (
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+
+ "github.com/sipeed/picoclaw/pkg/audio/tts"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/media"
+ "github.com/sipeed/picoclaw/pkg/skills"
+ integrationtools "github.com/sipeed/picoclaw/pkg/tools/integration"
+)
+
+type (
+ SendCallbackWithContext = integrationtools.SendCallbackWithContext
+ ReactionCallback = integrationtools.ReactionCallback
+ MCPManager = integrationtools.MCPManager
+ MCPTool = integrationtools.MCPTool
+ FindSkillsTool = integrationtools.FindSkillsTool
+ InstallSkillTool = integrationtools.InstallSkillTool
+ MessageTool = integrationtools.MessageTool
+ ReactionTool = integrationtools.ReactionTool
+ SendTTSTool = integrationtools.SendTTSTool
+ APIKeyPool = integrationtools.APIKeyPool
+ APIKeyIterator = integrationtools.APIKeyIterator
+ SearchProvider = integrationtools.SearchProvider
+ SearchResultItem = integrationtools.SearchResultItem
+ BraveSearchProvider = integrationtools.BraveSearchProvider
+ TavilySearchProvider = integrationtools.TavilySearchProvider
+ SogouSearchProvider = integrationtools.SogouSearchProvider
+ DuckDuckGoSearchProvider = integrationtools.DuckDuckGoSearchProvider
+ PerplexitySearchProvider = integrationtools.PerplexitySearchProvider
+ SearXNGSearchProvider = integrationtools.SearXNGSearchProvider
+ GLMSearchProvider = integrationtools.GLMSearchProvider
+ BaiduSearchProvider = integrationtools.BaiduSearchProvider
+ WebSearchTool = integrationtools.WebSearchTool
+ WebSearchToolOptions = integrationtools.WebSearchToolOptions
+ WebFetchTool = integrationtools.WebFetchTool
+)
+
+func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool {
+ return integrationtools.NewMCPTool(manager, serverName, tool)
+}
+
+func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool {
+ return integrationtools.NewFindSkillsTool(registryMgr, cache)
+}
+
+func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool {
+ return integrationtools.NewInstallSkillTool(registryMgr, workspace)
+}
+
+func NewMessageTool() *MessageTool {
+ return integrationtools.NewMessageTool()
+}
+
+func NewReactionTool() *ReactionTool {
+ return integrationtools.NewReactionTool()
+}
+
+func NewSendTTSTool(provider tts.TTSProvider, store media.MediaStore) *SendTTSTool {
+ return integrationtools.NewSendTTSTool(provider, store)
+}
+
+func NewAPIKeyPool(keys []string) *APIKeyPool {
+ return integrationtools.NewAPIKeyPool(keys)
+}
+
+func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions {
+ return integrationtools.WebSearchToolOptionsFromConfig(cfg)
+}
+
+func WebSearchProviderReady(opts WebSearchToolOptions, name string) bool {
+ return integrationtools.WebSearchProviderReady(opts, name)
+}
+
+func ResolveWebSearchProviderName(opts WebSearchToolOptions, query string) (string, error) {
+ return integrationtools.ResolveWebSearchProviderName(opts, query)
+}
+
+func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
+ return integrationtools.NewWebSearchTool(opts)
+}
+
+func NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) {
+ return integrationtools.NewWebFetchTool(maxChars, format, fetchLimitBytes)
+}
+
+func NewWebFetchToolWithProxy(
+ maxChars int,
+ proxy string,
+ format string,
+ fetchLimitBytes int64,
+ privateHostWhitelist []string,
+) (*WebFetchTool, error) {
+ return integrationtools.NewWebFetchToolWithProxy(maxChars, proxy, format, fetchLimitBytes, privateHostWhitelist)
+}
+
+func NewWebFetchToolWithConfig(
+ maxChars int,
+ proxy string,
+ format string,
+ fetchLimitBytes int64,
+ privateHostWhitelist []string,
+) (*WebFetchTool, error) {
+ return integrationtools.NewWebFetchToolWithConfig(maxChars, proxy, format, fetchLimitBytes, privateHostWhitelist)
+}
diff --git a/pkg/tools/load_image_compat_test.go b/pkg/tools/load_image_compat_test.go
new file mode 100644
index 000000000..a29ee2042
--- /dev/null
+++ b/pkg/tools/load_image_compat_test.go
@@ -0,0 +1,29 @@
+package tools
+
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func TestSubagentManager_SetMediaResolver_StoresResolver(t *testing.T) {
+ manager := NewSubagentManager(nil, "gpt-test", "/tmp")
+
+ called := false
+ manager.SetMediaResolver(func(msgs []providers.Message) []providers.Message {
+ called = true
+ return msgs
+ })
+
+ manager.mu.RLock()
+ got := manager.mediaResolver
+ manager.mu.RUnlock()
+
+ if got == nil {
+ t.Fatal("expected mediaResolver to be set")
+ }
+
+ if called {
+ t.Fatal("resolver should not be called during SetMediaResolver")
+ }
+}
diff --git a/pkg/tools/message.go b/pkg/tools/message.go
deleted file mode 100644
index 438ceeddd..000000000
--- a/pkg/tools/message.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package tools
-
-import (
- "context"
- "fmt"
- "sync/atomic"
-)
-
-type SendCallback func(channel, chatID, content string) error
-
-type MessageTool struct {
- sendCallback SendCallback
- sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round
-}
-
-func NewMessageTool() *MessageTool {
- return &MessageTool{}
-}
-
-func (t *MessageTool) Name() string {
- return "message"
-}
-
-func (t *MessageTool) Description() string {
- return "Send a message to user on a chat channel. Use this when you want to communicate something."
-}
-
-func (t *MessageTool) Parameters() map[string]any {
- return map[string]any{
- "type": "object",
- "properties": map[string]any{
- "content": map[string]any{
- "type": "string",
- "description": "The message content to send",
- },
- "channel": map[string]any{
- "type": "string",
- "description": "Optional: target channel (telegram, whatsapp, etc.)",
- },
- "chat_id": map[string]any{
- "type": "string",
- "description": "Optional: target chat/user ID",
- },
- },
- "required": []string{"content"},
- }
-}
-
-// ResetSentInRound resets the per-round send tracker.
-// Called by the agent loop at the start of each inbound message processing round.
-func (t *MessageTool) ResetSentInRound() {
- t.sentInRound.Store(false)
-}
-
-// HasSentInRound returns true if the message tool sent a message during the current round.
-func (t *MessageTool) HasSentInRound() bool {
- return t.sentInRound.Load()
-}
-
-func (t *MessageTool) SetSendCallback(callback SendCallback) {
- t.sendCallback = callback
-}
-
-func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
- content, ok := args["content"].(string)
- if !ok {
- return &ToolResult{ForLLM: "content is required", IsError: true}
- }
-
- channel, _ := args["channel"].(string)
- chatID, _ := args["chat_id"].(string)
-
- if channel == "" {
- channel = ToolChannel(ctx)
- }
- if chatID == "" {
- chatID = ToolChatID(ctx)
- }
-
- if channel == "" || chatID == "" {
- return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true}
- }
-
- if t.sendCallback == nil {
- return &ToolResult{ForLLM: "Message sending not configured", IsError: true}
- }
-
- if err := t.sendCallback(channel, chatID, content); err != nil {
- return &ToolResult{
- ForLLM: fmt.Sprintf("sending message: %v", err),
- IsError: true,
- Err: err,
- }
- }
-
- t.sentInRound.Store(true)
- // Silent: user already received the message directly
- return &ToolResult{
- ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),
- Silent: true,
- }
-}
diff --git a/pkg/tools/path_compat.go b/pkg/tools/path_compat.go
new file mode 100644
index 000000000..9e677cb2b
--- /dev/null
+++ b/pkg/tools/path_compat.go
@@ -0,0 +1,19 @@
+package tools
+
+import (
+ "regexp"
+
+ fstools "github.com/sipeed/picoclaw/pkg/tools/fs"
+)
+
+func validatePathWithAllowPaths(
+ path, workspace string,
+ restrict bool,
+ patterns []*regexp.Regexp,
+) (string, error) {
+ return fstools.ValidatePathWithAllowPaths(path, workspace, restrict, patterns)
+}
+
+func isAllowedPath(path string, patterns []*regexp.Regexp) bool {
+ return fstools.IsAllowedPath(path, patterns)
+}
diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go
index 1e6263dc8..a68746b82 100644
--- a/pkg/tools/registry.go
+++ b/pkg/tools/registry.go
@@ -278,6 +278,7 @@ func (r *ToolRegistry) ExecuteWithContext(
func() {
defer func() {
if re := recover(); re != nil {
+ logger.RecoverPanicNoExit(re)
errMsg := fmt.Sprintf("Tool '%s' crashed with panic: %v", name, re)
logger.ErrorCF("tool", "Tool execution panic recovered",
map[string]any{
@@ -401,6 +402,7 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
name, _ := fn["name"].(string)
desc, _ := fn["description"].(string)
params, _ := fn["parameters"].(map[string]any)
+ metadata := promptMetadataForTool(entry.Tool)
definitions = append(definitions, providers.ToolDefinition{
Type: "function",
@@ -409,11 +411,35 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
Description: desc,
Parameters: params,
},
+ PromptLayer: metadata.Layer,
+ PromptSlot: metadata.Slot,
+ PromptSource: metadata.Source,
})
}
return definitions
}
+func promptMetadataForTool(tool Tool) PromptMetadata {
+ metadata := PromptMetadata{
+ Layer: ToolPromptLayerCapability,
+ Slot: ToolPromptSlotTooling,
+ Source: ToolPromptSourceRegistry,
+ }
+ if provider, ok := tool.(PromptMetadataProvider); ok {
+ provided := provider.PromptMetadata()
+ if provided.Layer != "" {
+ metadata.Layer = provided.Layer
+ }
+ if provided.Slot != "" {
+ metadata.Slot = provided.Slot
+ }
+ if provided.Source != "" {
+ metadata.Source = provided.Source
+ }
+ }
+ return metadata
+}
+
// List returns a list of all registered tool names.
func (r *ToolRegistry) List() []string {
r.mu.RLock()
diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go
index 2633411ff..5ce79e227 100644
--- a/pkg/tools/registry_test.go
+++ b/pkg/tools/registry_test.go
@@ -39,6 +39,15 @@ func (m *mockContextAwareTool) Execute(ctx context.Context, _ map[string]any) *T
return m.result
}
+type mockPromptMetadataTool struct {
+ mockRegistryTool
+ metadata PromptMetadata
+}
+
+func (m *mockPromptMetadataTool) PromptMetadata() PromptMetadata {
+ return m.metadata
+}
+
type mockAsyncRegistryTool struct {
mockRegistryTool
lastCB AsyncCallback
@@ -216,6 +225,33 @@ func TestToolRegistry_ExecuteWithContext_EmptyContext(t *testing.T) {
}
}
+func TestToolRegistry_ExecuteWithContext_PreservesMessageContext(t *testing.T) {
+ r := NewToolRegistry()
+ ct := &mockContextAwareTool{
+ mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
+ }
+ r.Register(ct)
+
+ baseCtx := WithToolMessageContext(context.Background(), "msg-123", "msg-100")
+ r.ExecuteWithContext(baseCtx, "ctx_tool", nil, "telegram", "chat-42", nil)
+
+ if ct.lastCtx == nil {
+ t.Fatal("expected Execute to be called")
+ }
+ if got := ToolChannel(ct.lastCtx); got != "telegram" {
+ t.Errorf("expected channel 'telegram', got %q", got)
+ }
+ if got := ToolChatID(ct.lastCtx); got != "chat-42" {
+ t.Errorf("expected chatID 'chat-42', got %q", got)
+ }
+ if got := ToolMessageID(ct.lastCtx); got != "msg-123" {
+ t.Errorf("expected messageID 'msg-123', got %q", got)
+ }
+ if got := ToolReplyToMessageID(ct.lastCtx); got != "msg-100" {
+ t.Errorf("expected replyToMessageID 'msg-100', got %q", got)
+ }
+}
+
func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
r := NewToolRegistry()
at := &mockAsyncRegistryTool{
@@ -378,6 +414,47 @@ func TestToolToSchema(t *testing.T) {
}
}
+func TestToolRegistry_ToProviderDefsAttachesPromptMetadata(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("native", "native tool"))
+ r.Register(&mockPromptMetadataTool{
+ mockRegistryTool: mockRegistryTool{
+ name: "mcp_demo",
+ desc: "mcp tool",
+ params: map[string]any{"type": "object"},
+ },
+ metadata: PromptMetadata{
+ Layer: ToolPromptLayerCapability,
+ Slot: ToolPromptSlotMCP,
+ Source: "mcp:demo",
+ },
+ })
+
+ defs := r.ToProviderDefs()
+ if len(defs) != 2 {
+ t.Fatalf("ToProviderDefs() len = %d, want 2", len(defs))
+ }
+
+ byName := make(map[string]providers.ToolDefinition, len(defs))
+ for _, def := range defs {
+ byName[def.Function.Name] = def
+ }
+
+ native := byName["native"]
+ if native.PromptLayer != ToolPromptLayerCapability ||
+ native.PromptSlot != ToolPromptSlotTooling ||
+ native.PromptSource != ToolPromptSourceRegistry {
+ t.Fatalf("native prompt metadata = %#v, want default tooling source", native)
+ }
+
+ mcp := byName["mcp_demo"]
+ if mcp.PromptLayer != ToolPromptLayerCapability ||
+ mcp.PromptSlot != ToolPromptSlotMCP ||
+ mcp.PromptSource != "mcp:demo" {
+ t.Fatalf("mcp prompt metadata = %#v, want mcp source", mcp)
+ }
+}
+
func TestToolRegistry_Clone(t *testing.T) {
r := NewToolRegistry()
r.Register(newMockTool("read_file", "reads files"))
diff --git a/pkg/tools/search_tool.go b/pkg/tools/search_tool.go
index f41c80d90..c5884c9de 100644
--- a/pkg/tools/search_tool.go
+++ b/pkg/tools/search_tool.go
@@ -34,6 +34,14 @@ func (t *RegexSearchTool) Description() string {
return "Search available hidden tools on-demand using a regex pattern. Returns JSON schemas of discovered tools."
}
+func (t *RegexSearchTool) PromptMetadata() PromptMetadata {
+ return PromptMetadata{
+ Layer: ToolPromptLayerCapability,
+ Slot: ToolPromptSlotTooling,
+ Source: ToolPromptSourceDiscovery,
+ }
+}
+
func (t *RegexSearchTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
@@ -95,6 +103,14 @@ func (t *BM25SearchTool) Description() string {
return "Search available hidden tools on-demand using natural language query describing the action you need to perform. Returns JSON schemas of discovered tools."
}
+func (t *BM25SearchTool) PromptMetadata() PromptMetadata {
+ return PromptMetadata{
+ Layer: ToolPromptLayerCapability,
+ Slot: ToolPromptSlotTooling,
+ Source: ToolPromptSourceDiscovery,
+ }
+}
+
func (t *BM25SearchTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
diff --git a/pkg/tools/session.go b/pkg/tools/session.go
index 141dd4b5e..8c7584254 100644
--- a/pkg/tools/session.go
+++ b/pkg/tools/session.go
@@ -242,11 +242,3 @@ func (sm *SessionManager) List() []SessionInfo {
func generateSessionID() string {
return uuid.New().String()[:8]
}
-
-type SessionInfo struct {
- ID string `json:"id"`
- Command string `json:"command"`
- Status string `json:"status"`
- PID int `json:"pid"`
- StartedAt int64 `json:"startedAt"`
-}
diff --git a/pkg/tools/base.go b/pkg/tools/shared/base.go
similarity index 51%
rename from pkg/tools/base.go
rename to pkg/tools/shared/base.go
index ec743e164..298e1b478 100644
--- a/pkg/tools/base.go
+++ b/pkg/tools/shared/base.go
@@ -1,6 +1,10 @@
-package tools
+package toolshared
-import "context"
+import (
+ "context"
+
+ "github.com/sipeed/picoclaw/pkg/session"
+)
// Tool is the interface that all tools must implement.
type Tool interface {
@@ -10,6 +14,24 @@ type Tool interface {
Execute(ctx context.Context, args map[string]any) *ToolResult
}
+const (
+ ToolPromptLayerCapability = "capability"
+ ToolPromptSlotTooling = "tooling"
+ ToolPromptSlotMCP = "mcp"
+ ToolPromptSourceRegistry = "tool_registry:native"
+ ToolPromptSourceDiscovery = "tool_registry:discovery"
+)
+
+type PromptMetadata struct {
+ Layer string
+ Slot string
+ Source string
+}
+
+type PromptMetadataProvider interface {
+ PromptMetadata() PromptMetadata
+}
+
// --- Request-scoped tool context (channel / chatID) ---
//
// Carried via context.Value so that concurrent tool calls each receive
@@ -21,8 +43,13 @@ type Tool interface {
type toolCtxKey struct{ name string }
var (
- ctxKeyChannel = &toolCtxKey{"channel"}
- ctxKeyChatID = &toolCtxKey{"chatID"}
+ ctxKeyChannel = &toolCtxKey{"channel"}
+ ctxKeyChatID = &toolCtxKey{"chatID"}
+ ctxKeyMessageID = &toolCtxKey{"messageID"}
+ ctxKeyReplyToMessageID = &toolCtxKey{"replyToMessageID"}
+ ctxKeyAgentID = &toolCtxKey{"agentID"}
+ ctxKeySessionKey = &toolCtxKey{"sessionKey"}
+ ctxKeySessionScope = &toolCtxKey{"sessionScope"}
)
// WithToolContext returns a child context carrying channel and chatID.
@@ -32,6 +59,35 @@ func WithToolContext(ctx context.Context, channel, chatID string) context.Contex
return ctx
}
+// WithToolMessageContext returns a child context carrying inbound message IDs.
+func WithToolMessageContext(ctx context.Context, messageID, replyToMessageID string) context.Context {
+ ctx = context.WithValue(ctx, ctxKeyMessageID, messageID)
+ ctx = context.WithValue(ctx, ctxKeyReplyToMessageID, replyToMessageID)
+ return ctx
+}
+
+// WithToolInboundContext returns a child context carrying channel/chat and inbound IDs.
+func WithToolInboundContext(
+ ctx context.Context,
+ channel, chatID, messageID, replyToMessageID string,
+) context.Context {
+ ctx = WithToolContext(ctx, channel, chatID)
+ ctx = WithToolMessageContext(ctx, messageID, replyToMessageID)
+ return ctx
+}
+
+// WithToolSessionContext returns a child context carrying turn-scoped session metadata.
+func WithToolSessionContext(
+ ctx context.Context,
+ agentID, sessionKey string,
+ scope *session.SessionScope,
+) context.Context {
+ ctx = context.WithValue(ctx, ctxKeyAgentID, agentID)
+ ctx = context.WithValue(ctx, ctxKeySessionKey, sessionKey)
+ ctx = context.WithValue(ctx, ctxKeySessionScope, session.CloneScope(scope))
+ return ctx
+}
+
// ToolChannel extracts the channel from ctx, or "" if unset.
func ToolChannel(ctx context.Context) string {
v, _ := ctx.Value(ctxKeyChannel).(string)
@@ -44,6 +100,36 @@ func ToolChatID(ctx context.Context) string {
return v
}
+// ToolMessageID extracts the current inbound message ID from ctx, or "" if unset.
+func ToolMessageID(ctx context.Context) string {
+ v, _ := ctx.Value(ctxKeyMessageID).(string)
+ return v
+}
+
+// ToolReplyToMessageID extracts the current inbound reply target from ctx, or "" if unset.
+func ToolReplyToMessageID(ctx context.Context) string {
+ v, _ := ctx.Value(ctxKeyReplyToMessageID).(string)
+ return v
+}
+
+// ToolAgentID extracts the active turn's agent ID from ctx, or "" if unset.
+func ToolAgentID(ctx context.Context) string {
+ v, _ := ctx.Value(ctxKeyAgentID).(string)
+ return v
+}
+
+// ToolSessionKey extracts the active turn's session key from ctx, or "" if unset.
+func ToolSessionKey(ctx context.Context) string {
+ v, _ := ctx.Value(ctxKeySessionKey).(string)
+ return v
+}
+
+// ToolSessionScope extracts the active turn's structured session scope from ctx.
+func ToolSessionScope(ctx context.Context) *session.SessionScope {
+ scope, _ := ctx.Value(ctxKeySessionScope).(*session.SessionScope)
+ return session.CloneScope(scope)
+}
+
// AsyncCallback is a function type that async tools use to notify completion.
// When an async tool finishes its work, it calls this callback with the result.
//
diff --git a/pkg/tools/result.go b/pkg/tools/shared/result.go
similarity index 95%
rename from pkg/tools/result.go
rename to pkg/tools/shared/result.go
index c81213125..e4b16f7b3 100644
--- a/pkg/tools/result.go
+++ b/pkg/tools/shared/result.go
@@ -1,4 +1,4 @@
-package tools
+package toolshared
import (
"encoding/json"
@@ -8,8 +8,8 @@ import (
)
const (
- handledToolLLMNote = "The requested output has already been delivered to the user in the current chat. Do not call send_file or any other delivery tool again. If you reply, provide only a brief confirmation."
- artifactPathsLLMNote = "Use `send_file` with one of these paths to send it to the user, or use file/exec tools to save it inside the workspace if requested."
+ HandledToolLLMNote = "The requested output has already been delivered to the user in the current chat. Do not call send_file or any other delivery tool again. If you reply, provide only a brief confirmation."
+ ArtifactPathsLLMNote = "Use `send_file` with one of these paths to send it to the user, or use file/exec tools to save it inside the workspace if requested."
)
// ToolResult represents the structured return value from tool execution.
@@ -73,14 +73,14 @@ func (tr *ToolResult) ContentForLLM() string {
}
if tr.ResponseHandled {
if content == "" {
- return handledToolLLMNote
+ return HandledToolLLMNote
}
- if !strings.Contains(content, handledToolLLMNote) {
- content += "\n" + handledToolLLMNote
+ if !strings.Contains(content, HandledToolLLMNote) {
+ content += "\n" + HandledToolLLMNote
}
}
if len(tr.ArtifactTags) > 0 {
- artifactNote := "Local artifact paths: " + strings.Join(tr.ArtifactTags, " ") + "\n" + artifactPathsLLMNote
+ artifactNote := "Local artifact paths: " + strings.Join(tr.ArtifactTags, " ") + "\n" + ArtifactPathsLLMNote
if content == "" {
content = artifactNote
} else if !strings.Contains(content, artifactNote) {
diff --git a/pkg/tools/types.go b/pkg/tools/shared/types.go
similarity index 91%
rename from pkg/tools/types.go
rename to pkg/tools/shared/types.go
index 4d1a18d5a..8a74d30f3 100644
--- a/pkg/tools/types.go
+++ b/pkg/tools/shared/types.go
@@ -1,4 +1,4 @@
-package tools
+package toolshared
import "context"
@@ -77,3 +77,11 @@ type ExecResponse struct {
Error string `json:"error,omitempty"`
Sessions []SessionInfo `json:"sessions,omitempty"`
}
+
+type SessionInfo struct {
+ ID string `json:"id"`
+ Command string `json:"command"`
+ Status string `json:"status"`
+ PID int `json:"pid"`
+ StartedAt int64 `json:"startedAt"`
+}
diff --git a/pkg/tools/shared_facade.go b/pkg/tools/shared_facade.go
new file mode 100644
index 000000000..8409ea060
--- /dev/null
+++ b/pkg/tools/shared_facade.go
@@ -0,0 +1,118 @@
+package tools
+
+import (
+ "context"
+
+ "github.com/sipeed/picoclaw/pkg/session"
+ toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
+)
+
+type (
+ Message = toolshared.Message
+ ToolCall = toolshared.ToolCall
+ FunctionCall = toolshared.FunctionCall
+ LLMResponse = toolshared.LLMResponse
+ UsageInfo = toolshared.UsageInfo
+ LLMProvider = toolshared.LLMProvider
+ ToolDefinition = toolshared.ToolDefinition
+ ToolFunctionDefinition = toolshared.ToolFunctionDefinition
+ ExecRequest = toolshared.ExecRequest
+ ExecResponse = toolshared.ExecResponse
+ SessionInfo = toolshared.SessionInfo
+ Tool = toolshared.Tool
+ AsyncCallback = toolshared.AsyncCallback
+ AsyncExecutor = toolshared.AsyncExecutor
+ PromptMetadata = toolshared.PromptMetadata
+ PromptMetadataProvider = toolshared.PromptMetadataProvider
+ ToolResult = toolshared.ToolResult
+)
+
+const (
+ handledToolLLMNote = toolshared.HandledToolLLMNote
+ artifactPathsLLMNote = toolshared.ArtifactPathsLLMNote
+
+ ToolPromptLayerCapability = toolshared.ToolPromptLayerCapability
+ ToolPromptSlotTooling = toolshared.ToolPromptSlotTooling
+ ToolPromptSlotMCP = toolshared.ToolPromptSlotMCP
+ ToolPromptSourceRegistry = toolshared.ToolPromptSourceRegistry
+ ToolPromptSourceDiscovery = toolshared.ToolPromptSourceDiscovery
+)
+
+func WithToolContext(ctx context.Context, channel, chatID string) context.Context {
+ return toolshared.WithToolContext(ctx, channel, chatID)
+}
+
+func WithToolMessageContext(ctx context.Context, messageID, replyToMessageID string) context.Context {
+ return toolshared.WithToolMessageContext(ctx, messageID, replyToMessageID)
+}
+
+func WithToolInboundContext(
+ ctx context.Context,
+ channel, chatID, messageID, replyToMessageID string,
+) context.Context {
+ return toolshared.WithToolInboundContext(ctx, channel, chatID, messageID, replyToMessageID)
+}
+
+func WithToolSessionContext(
+ ctx context.Context,
+ agentID, sessionKey string,
+ scope *session.SessionScope,
+) context.Context {
+ return toolshared.WithToolSessionContext(ctx, agentID, sessionKey, scope)
+}
+
+func ToolChannel(ctx context.Context) string {
+ return toolshared.ToolChannel(ctx)
+}
+
+func ToolChatID(ctx context.Context) string {
+ return toolshared.ToolChatID(ctx)
+}
+
+func ToolMessageID(ctx context.Context) string {
+ return toolshared.ToolMessageID(ctx)
+}
+
+func ToolReplyToMessageID(ctx context.Context) string {
+ return toolshared.ToolReplyToMessageID(ctx)
+}
+
+func ToolAgentID(ctx context.Context) string {
+ return toolshared.ToolAgentID(ctx)
+}
+
+func ToolSessionKey(ctx context.Context) string {
+ return toolshared.ToolSessionKey(ctx)
+}
+
+func ToolSessionScope(ctx context.Context) *session.SessionScope {
+ return toolshared.ToolSessionScope(ctx)
+}
+
+func ToolToSchema(tool Tool) map[string]any {
+ return toolshared.ToolToSchema(tool)
+}
+
+func NewToolResult(forLLM string) *ToolResult {
+ return toolshared.NewToolResult(forLLM)
+}
+
+func SilentResult(forLLM string) *ToolResult {
+ return toolshared.SilentResult(forLLM)
+}
+
+func AsyncResult(forLLM string) *ToolResult {
+ return toolshared.AsyncResult(forLLM)
+}
+
+func ErrorResult(message string) *ToolResult {
+ return toolshared.ErrorResult(message)
+}
+
+func UserResult(content string) *ToolResult {
+ return toolshared.UserResult(content)
+}
+
+func MediaResult(forLLM string, mediaRefs []string) *ToolResult {
+ return toolshared.MediaResult(forLLM, mediaRefs)
+}
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index 6ee1cb993..a570ac9ec 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -20,6 +20,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
+ "github.com/sipeed/picoclaw/pkg/isolation"
)
var (
@@ -52,7 +53,7 @@ var (
regexp.MustCompile(`\brmdir\s+/s\b`),
// Match disk wiping commands (must be followed by space/args)
regexp.MustCompile(
- `\b(format|mkfs|diskpart)\b\s`,
+ `(^|[^-\w])\b(format|mkfs|diskpart)\b\s`,
),
regexp.MustCompile(`\bdd\s+if=`),
// Block writes to block devices (all common naming schemes).
@@ -120,7 +121,7 @@ func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regex
func NewExecToolWithConfig(
workingDir string,
restrict bool,
- config *config.Config,
+ cfg *config.Config,
allowPaths ...[]*regexp.Regexp,
) (*ExecTool, error) {
denyPatterns := make([]*regexp.Regexp, 0)
@@ -131,8 +132,8 @@ func NewExecToolWithConfig(
allowedPathPatterns = allowPaths[0]
}
- if config != nil {
- execConfig := config.Tools.Exec
+ if cfg != nil {
+ execConfig := cfg.Tools.Exec
enableDenyPatterns := execConfig.EnableDenyPatterns
allowRemote = execConfig.AllowRemote
if enableDenyPatterns {
@@ -163,8 +164,8 @@ func NewExecToolWithConfig(
}
var timeout time.Duration
- if config != nil && config.Tools.Exec.TimeoutSeconds > 0 {
- timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second
+ if cfg != nil && cfg.Tools.Exec.TimeoutSeconds > 0 {
+ timeout = time.Duration(cfg.Tools.Exec.TimeoutSeconds) * time.Second
}
return &ExecTool{
@@ -378,7 +379,9 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult
cmd.Stdout = &stdout
cmd.Stderr = &stderr
- if err := cmd.Start(); err != nil {
+ // Route shell execution through the shared isolation entry point so exec tool
+ // subprocesses receive the same isolation policy as other integrations.
+ if err := isolation.Start(cmd); err != nil {
return ErrorResult(fmt.Sprintf("failed to start command: %v", err))
}
@@ -521,7 +524,9 @@ func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEn
session.stdinWriter = stdinWriter
}
- if err := cmd.Start(); err != nil {
+ // Background sessions use the same startup path so isolation stays consistent
+ // with synchronous exec runs.
+ if err := isolation.Start(cmd); err != nil {
if session.ptyMaster != nil {
session.ptyMaster.Close()
}
diff --git a/pkg/tools/skills_install_test.go b/pkg/tools/skills_install_test.go
deleted file mode 100644
index 676fcecc0..000000000
--- a/pkg/tools/skills_install_test.go
+++ /dev/null
@@ -1,104 +0,0 @@
-package tools
-
-import (
- "context"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-
- "github.com/sipeed/picoclaw/pkg/skills"
-)
-
-func TestInstallSkillToolName(t *testing.T) {
- tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
- assert.Equal(t, "install_skill", tool.Name())
-}
-
-func TestInstallSkillToolMissingSlug(t *testing.T) {
- tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
- result := tool.Execute(context.Background(), map[string]any{})
- assert.True(t, result.IsError)
- assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string")
-}
-
-func TestInstallSkillToolEmptySlug(t *testing.T) {
- tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
- result := tool.Execute(context.Background(), map[string]any{
- "slug": " ",
- })
- assert.True(t, result.IsError)
- assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string")
-}
-
-func TestInstallSkillToolUnsafeSlug(t *testing.T) {
- tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
-
- cases := []string{
- "../etc/passwd",
- "path/traversal",
- "path\\traversal",
- }
-
- for _, slug := range cases {
- result := tool.Execute(context.Background(), map[string]any{
- "slug": slug,
- })
- assert.True(t, result.IsError, "slug %q should be rejected", slug)
- assert.Contains(t, result.ForLLM, "invalid slug")
- }
-}
-
-func TestInstallSkillToolAlreadyExists(t *testing.T) {
- workspace := t.TempDir()
- skillDir := filepath.Join(workspace, "skills", "existing-skill")
- require.NoError(t, os.MkdirAll(skillDir, 0o755))
-
- tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
- result := tool.Execute(context.Background(), map[string]any{
- "slug": "existing-skill",
- "registry": "clawhub",
- })
- assert.True(t, result.IsError)
- assert.Contains(t, result.ForLLM, "already installed")
-}
-
-func TestInstallSkillToolRegistryNotFound(t *testing.T) {
- workspace := t.TempDir()
- tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
- result := tool.Execute(context.Background(), map[string]any{
- "slug": "some-skill",
- "registry": "nonexistent",
- })
- assert.True(t, result.IsError)
- assert.Contains(t, result.ForLLM, "registry")
- assert.Contains(t, result.ForLLM, "not found")
-}
-
-func TestInstallSkillToolParameters(t *testing.T) {
- tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
- params := tool.Parameters()
-
- props, ok := params["properties"].(map[string]any)
- assert.True(t, ok)
- assert.Contains(t, props, "slug")
- assert.Contains(t, props, "version")
- assert.Contains(t, props, "registry")
- assert.Contains(t, props, "force")
-
- required, ok := params["required"].([]string)
- assert.True(t, ok)
- assert.Contains(t, required, "slug")
- assert.Contains(t, required, "registry")
-}
-
-func TestInstallSkillToolMissingRegistry(t *testing.T) {
- tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
- result := tool.Execute(context.Background(), map[string]any{
- "slug": "some-skill",
- })
- assert.True(t, result.IsError)
- assert.Contains(t, result.ForLLM, "invalid registry")
-}
diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go
index d019d511a..a9a373856 100644
--- a/pkg/tools/spawn.go
+++ b/pkg/tools/spawn.go
@@ -92,11 +92,12 @@ func (t *SpawnTool) execute(
label, _ := args["label"].(string)
agentID, _ := args["agent_id"].(string)
+ targetAgentID := strings.TrimSpace(agentID)
// Check allowlist if targeting a specific agent
- if agentID != "" && t.allowlistCheck != nil {
- if !t.allowlistCheck(agentID) {
- return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", agentID))
+ if targetAgentID != "" && t.allowlistCheck != nil {
+ if !t.allowlistCheck(targetAgentID) {
+ return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", targetAgentID))
}
}
@@ -123,12 +124,14 @@ Task: %s`,
// Launch async sub-turn in goroutine
go func() {
result, err := t.spawner.SpawnSubTurn(ctx, SubTurnConfig{
- Model: t.defaultModel,
- Tools: nil, // Will inherit from parent via context
- SystemPrompt: systemPrompt,
- MaxTokens: t.maxTokens,
- Temperature: t.temperature,
- Async: true, // Async execution
+ Model: t.defaultModel,
+ Tools: nil, // Will inherit from parent via context
+ SystemPrompt: systemPrompt,
+ MaxTokens: t.maxTokens,
+ Temperature: t.temperature,
+ Async: true, // Async execution
+ Critical: true, // Background spawn should survive parent turn completion
+ TargetAgentID: targetAgentID,
})
if err != nil {
result = ErrorResult(fmt.Sprintf("Spawn failed: %v", err)).WithError(err)
diff --git a/pkg/tools/spawn_test.go b/pkg/tools/spawn_test.go
index fda6bbd89..c91c79578 100644
--- a/pkg/tools/spawn_test.go
+++ b/pkg/tools/spawn_test.go
@@ -6,10 +6,18 @@ import (
"testing"
)
-// mockSpawner implements SubTurnSpawner for testing
-type mockSpawner struct{}
+// mockSpawner implements SubTurnSpawner for testing.
+type mockSpawner struct {
+ lastConfig SubTurnConfig
+ done chan struct{}
+}
func (m *mockSpawner) SpawnSubTurn(ctx context.Context, cfg SubTurnConfig) (*ToolResult, error) {
+ m.lastConfig = cfg
+ if m.done != nil {
+ close(m.done)
+ }
+
// Extract task from system prompt for response
task := cfg.SystemPrompt
if strings.Contains(task, "Task: ") {
@@ -62,12 +70,14 @@ func TestSpawnTool_Execute_ValidTask(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSpawnTool(manager)
- tool.SetSpawner(&mockSpawner{})
+ spawner := &mockSpawner{done: make(chan struct{})}
+ tool.SetSpawner(spawner)
ctx := context.Background()
args := map[string]any{
- "task": "Write a haiku about coding",
- "label": "haiku-task",
+ "task": "Write a haiku about coding",
+ "label": "haiku-task",
+ "agent_id": "research",
}
result := tool.Execute(ctx, args)
@@ -80,6 +90,13 @@ func TestSpawnTool_Execute_ValidTask(t *testing.T) {
if !result.Async {
t.Error("SpawnTool should return async result")
}
+ <-spawner.done
+ if spawner.lastConfig.TargetAgentID != "research" {
+ t.Errorf("TargetAgentID = %q, want research", spawner.lastConfig.TargetAgentID)
+ }
+ if !spawner.lastConfig.Critical {
+ t.Error("SpawnTool should mark background subturns as critical")
+ }
}
func TestSpawnTool_Execute_NilManager(t *testing.T) {
diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go
index 9a1a8b802..feeabe536 100644
--- a/pkg/tools/subagent.go
+++ b/pkg/tools/subagent.go
@@ -30,6 +30,7 @@ type SubTurnConfig struct {
ActualSystemPrompt string
InitialMessages []providers.Message
InitialTokenBudget *atomic.Int64 // Shared token budget for team members; nil if no budget
+ TargetAgentID string // If set, run as this agent (its workspace, model, tools)
}
type SubagentTask struct {
@@ -67,6 +68,12 @@ type SubagentManager struct {
hasTemperature bool
nextID int
spawner SpawnSubTurnFunc
+
+ // mediaResolver resolves media:// refs in tool-loop messages before
+ // each LLM call in the legacy RunToolLoop fallback path.
+ // This lets subagents reuse the same media handling behavior as the
+ // main agent loop without importing pkg/agent and creating a cycle.
+ mediaResolver func([]providers.Message) []providers.Message
}
func NewSubagentManager(
@@ -90,6 +97,17 @@ func (sm *SubagentManager) SetSpawner(spawner SpawnSubTurnFunc) {
sm.spawner = spawner
}
+// SetMediaResolver injects a message preprocessor that resolves media:// refs
+// into LLM-ready content before each tool-loop iteration.
+// This is only used by the legacy RunToolLoop fallback path.
+func (sm *SubagentManager) SetMediaResolver(
+ resolver func([]providers.Message) []providers.Message,
+) {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+ sm.mediaResolver = resolver
+}
+
// SetLLMOptions sets max tokens and temperature for subagent LLM calls.
func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
sm.mu.Lock()
@@ -177,6 +195,7 @@ func (sm *SubagentManager) runTask(
temperature := sm.temperature
hasMaxTokens := sm.hasMaxTokens
hasTemperature := sm.hasTemperature
+ mediaResolver := sm.mediaResolver
sm.mu.RUnlock()
var result *ToolResult
@@ -223,6 +242,7 @@ After completing the task, provide a clear summary of what was done.`
Tools: tools,
MaxIterations: maxIter,
LLMOptions: llmOptions,
+ MediaResolver: mediaResolver,
}, messages, task.OriginChannel, task.OriginChatID)
if err == nil {
diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go
index 387813e94..ac568f598 100644
--- a/pkg/tools/toolloop.go
+++ b/pkg/tools/toolloop.go
@@ -24,6 +24,11 @@ type ToolLoopConfig struct {
Tools *ToolRegistry
MaxIterations int
LLMOptions map[string]any
+
+ // MediaResolver resolves media:// refs in messages before each LLM call.
+ // This is optional and is mainly used by subagent legacy fallback execution
+ // so subagents can reuse the same multimodal media handling as the main loop.
+ MediaResolver func(messages []providers.Message) []providers.Message
}
// ToolLoopResult contains the result of running the tool loop.
@@ -63,8 +68,27 @@ func RunToolLoop(
if llmOpts == nil {
llmOpts = map[string]any{}
}
- // 3. Call LLM
- response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts)
+
+ // 3. Resolve media:// refs and Call LLM.
+ // Tools like load_image produce media:// refs in their result messages.
+ // Without this step, the LLM would receive raw "media://uuid" strings
+ // instead of base64-encoded image data URLs.
+ //
+ // We build a separate callMessages slice so that:
+ // (a) the resolver output is used for the LLM call only,
+ // (b) the original `messages` slice keeps the unresolved refs for
+ // subsequent iterations — the resolver is idempotent but working
+ // on the original avoids double-encoding issues.
+ //
+ // On iteration 1 the initial user messages typically have no media://
+ // refs (they come from plain text), so this is effectively a no-op;
+ // it becomes relevant from iteration 2 onward when tool results may
+ // contain media refs.
+ callMessages := messages
+ if config.MediaResolver != nil && iteration > 1 {
+ callMessages = config.MediaResolver(messages)
+ }
+ response, err := config.Provider.Chat(ctx, callMessages, providerToolDefs, config.Model, llmOpts)
if err != nil {
logger.ErrorCF("toolloop", "LLM call failed",
map[string]any{
@@ -161,11 +185,15 @@ func RunToolLoop(
for _, r := range results {
contentForLLM := r.result.ContentForLLM()
- messages = append(messages, providers.Message{
+ toolMsg := providers.Message{
Role: "tool",
Content: contentForLLM,
ToolCallID: r.tc.ID,
- })
+ }
+ if len(r.result.Media) > 0 && !r.result.ResponseHandled {
+ toolMsg.Media = append(toolMsg.Media, r.result.Media...)
+ }
+ messages = append(messages, toolMsg)
}
}
diff --git a/pkg/updater/updater.go b/pkg/updater/updater.go
new file mode 100644
index 000000000..2d4cc950e
--- /dev/null
+++ b/pkg/updater/updater.go
@@ -0,0 +1,717 @@
+package updater
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "compress/gzip"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/minio/selfupdate"
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/utils"
+)
+
+// httpClient is a shared HTTP client used for release checks and downloads.
+// The Timeout value applies to the entire HTTP request: dialing, TLS
+// handshake, redirects, and reading the response body. It is NOT only
+// a connection (dial) timeout. To control lower-level timeouts (dial,
+// TLS handshake, response header wait), supply a custom Transport with
+// an appropriately configured net.Dialer.
+var httpClient = &http.Client{Timeout: 2 * time.Minute}
+
+func getWithRetry(rawURL string) (*http.Response, error) {
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ return utils.DoRequestWithRetry(httpClient, req)
+}
+
+// DownloadAndExtractRelease downloads a release archive (or uses a direct
+// asset URL) and extracts it to a temporary directory. It returns the
+// extraction directory on success. If releaseURL is empty, the latest
+// release of the current project is used. platform/arch can be used to
+// select the correct asset (e.g. "linux", "amd64").
+func DownloadAndExtractRelease(releaseURL, platform, arch string) (string, error) {
+ assetURL, checksum, err := findAssetInfo(releaseURL, platform, arch)
+ if err != nil {
+ return "", err
+ }
+
+ // Download asset to temp file. Use the asset URL extension so
+ // extractArchive can detect the archive format (zip/tar.gz/tar).
+ tmpPattern := "picoclaw-release-*"
+ if u, perr := url.Parse(assetURL); perr == nil {
+ base := filepath.Base(u.Path)
+ lbase := strings.ToLower(base)
+ switch {
+ case strings.HasSuffix(lbase, ".zip"):
+ tmpPattern += ".zip"
+ case strings.HasSuffix(lbase, ".tar.gz") || strings.HasSuffix(lbase, ".tgz"):
+ tmpPattern += ".tar.gz"
+ case strings.HasSuffix(lbase, ".tar"):
+ tmpPattern += ".tar"
+ default:
+ tmpPattern += ".archive"
+ }
+ } else {
+ tmpPattern += ".archive"
+ }
+
+ tmpFile, err := os.CreateTemp("", tmpPattern)
+ if err != nil {
+ return "", err
+ }
+ tmpPath := tmpFile.Name()
+ defer tmpFile.Close()
+
+ resp, err := getWithRetry(assetURL)
+ if err != nil {
+ os.Remove(tmpPath)
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ os.Remove(tmpPath)
+ return "", fmt.Errorf("failed to download asset: status %d", resp.StatusCode)
+ }
+
+ // Stream download while computing SHA256 to avoid a second download.
+ // Also show a simple progress line to stderr so users see activity.
+ h := sha256.New()
+ pw := &progressWriter{total: resp.ContentLength}
+ mw := io.MultiWriter(tmpFile, h, pw)
+ if _, err = io.Copy(mw, resp.Body); err != nil {
+ _ = os.Remove(tmpPath)
+ return "", err
+ }
+ // ensure final progress line ends with newline
+ pw.Finish()
+
+ // verify checksum if available
+ if checksum != "" {
+ got := hex.EncodeToString(h.Sum(nil))
+ if !strings.EqualFold(got, checksum) {
+ _ = os.Remove(tmpPath)
+ return "", fmt.Errorf("checksum mismatch: got %s expected %s", got, checksum)
+ }
+ }
+
+ // Extract
+ destDir, err := os.MkdirTemp("", "picoclaw-extract-*")
+ if err != nil {
+ os.Remove(tmpPath)
+ return "", err
+ }
+
+ if err := extractArchive(tmpPath, destDir); err != nil {
+ os.Remove(tmpPath)
+ os.RemoveAll(destDir)
+ return "", err
+ }
+
+ // cleanup archive file; keep extracted contents
+ _ = os.Remove(tmpPath)
+ return destDir, nil
+}
+
+// UpdateSelfFromRelease downloads the release matching the given parameters,
+// extracts it and applies the binary named programName to update the
+// currently running executable using minio/selfupdate.
+// If releaseURL is empty, the latest release is used. If platform or arch
+// is empty, runtime values are used.
+func UpdateSelfFromRelease(releaseURL, platform, arch, programName string) error {
+ if platform == "" {
+ platform = runtime.GOOS
+ }
+ if arch == "" {
+ arch = runtime.GOARCH
+ }
+
+ dir, err := DownloadAndExtractRelease(releaseURL, platform, arch)
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(dir)
+
+ binPath, err := findBinaryInDir(dir, programName)
+ if err != nil {
+ return err
+ }
+
+ // ensure executable bit on non-windows
+ if runtime.GOOS != "windows" {
+ _ = os.Chmod(binPath, 0o755)
+ }
+
+ f, err := os.Open(binPath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ // Backup current executable so we can roll back if needed.
+ var opts selfupdate.Options
+ if exePath, err := os.Executable(); err == nil {
+ opts.OldSavePath = exePath + ".old"
+ }
+
+ if err := selfupdate.Apply(f, opts); err != nil {
+ return fmt.Errorf("apply update: %w", err)
+ }
+
+ return nil
+}
+
+// UpdateSelf updates the running executable by fetching the latest release
+// and applying the binary matching programName.
+func UpdateSelf(programName string) error {
+ // By default, select the latest stable release when no explicit
+ // release URL is provided. Use --nightly or a custom URL to override.
+ return UpdateSelfFromRelease("", runtime.GOOS, runtime.GOARCH, programName)
+}
+
+// GetReleaseAPIURL returns the GitHub Releases API URL for the given repo owner.
+// Example: owner="sky5454" -> https://api.github.com/repos/sky5454/picoclaw/releases/latest
+func GetReleaseAPIURL(owner string) string {
+ return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/latest", owner)
+}
+
+// GetProdReleaseAPIURL returns the production release API URL (upstream).
+func GetProdReleaseAPIURL() string {
+ return GetReleaseAPIURL("sipeed")
+}
+
+// GetReleaseTagAPIURL returns the GitHub Releases API URL for a specific tag.
+// Example: owner="sipeed", tag="nightly" -> https://api.github.com/repos/sipeed/picoclaw/releases/tags/nightly
+func GetReleaseTagAPIURL(owner, tag string) string {
+ return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/tags/%s", owner, tag)
+}
+
+// GetNightlyReleaseAPIURL returns the nightly release API URL for the production repo.
+func GetNightlyReleaseAPIURL() string {
+ return GetReleaseTagAPIURL("sipeed", "nightly")
+}
+
+// findAssetURL resolves the appropriate asset URL for the given release
+// selector. It accepts direct archive URLs as well as GitHub release URLs
+// or empty (latest release for the project).
+func findAssetInfo(releaseURL, platform, arch string) (string, string, error) {
+ // returns (assetURL, sha256ChecksumHex, error)
+ if looksLikeDirectAssetURL(releaseURL) {
+ return "", "", fmt.Errorf("no checksum found for asset %s", releaseURL)
+ }
+
+ apiURL := buildReleaseAPIURL(releaseURL)
+ if apiURL == "" {
+ // If caller provided an empty releaseURL, default to the
+ // production latest release API URL (stable release).
+ apiURL = GetProdReleaseAPIURL()
+ }
+
+ resp, err := getWithRetry(apiURL)
+ if err != nil {
+ return "", "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return "", "", fmt.Errorf("failed to query releases: status %d", resp.StatusCode)
+ }
+
+ var data struct {
+ TagName string `json:"tag_name"`
+ Assets []struct {
+ Name string `json:"name"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+ Digest string `json:"digest"`
+ } `json:"assets"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ return "", "", err
+ }
+
+ // Selection order: platform -> arch -> extension.
+ platformLower := strings.ToLower(platform)
+ archLower := strings.ToLower(arch)
+
+ isZip := func(name string) bool {
+ return strings.HasSuffix(name, ".zip")
+ }
+ isTarGz := func(name string) bool {
+ return strings.HasSuffix(name, ".tar.gz") || strings.HasSuffix(name, ".tgz")
+ }
+ isTar := func(name string) bool { return strings.HasSuffix(name, ".tar") }
+
+ // collect indices of assets that contain platform (if provided)
+ var platformIdx []int
+ for i, a := range data.Assets {
+ n := strings.ToLower(a.Name)
+ if platform == "" || strings.Contains(n, platformLower) {
+ platformIdx = append(platformIdx, i)
+ }
+ }
+
+ pickBest := func(idxs []int) (string, int, bool) {
+ if len(idxs) == 0 {
+ return "", -1, false
+ }
+ // prefer arch matches within idxs; if arch was specified but
+ // no arch match exists among idxs, treat as no candidate.
+ var archIdx []int
+ if arch != "" {
+ aliases := archAliases(archLower)
+ for _, i := range idxs {
+ n := strings.ToLower(data.Assets[i].Name)
+ for _, ali := range aliases {
+ if strings.Contains(n, ali) {
+ archIdx = append(archIdx, i)
+ break
+ }
+ }
+ }
+ if len(archIdx) == 0 {
+ return "", -1, false
+ }
+ }
+ candidates := archIdx
+ if len(candidates) == 0 {
+ candidates = idxs
+ }
+
+ // extension preference
+ if platformLower == "windows" {
+ // prefer .zip only
+ for _, i := range candidates {
+ if isZip(strings.ToLower(data.Assets[i].Name)) {
+ return data.Assets[i].BrowserDownloadURL, i, true
+ }
+ }
+ // if no zip found, fallthrough to first candidate
+ return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true
+ }
+
+ // non-windows: prefer tar.gz/tgz, then tar, then zip
+ for _, i := range candidates {
+ if isTarGz(strings.ToLower(data.Assets[i].Name)) {
+ return data.Assets[i].BrowserDownloadURL, i, true
+ }
+ }
+ for _, i := range candidates {
+ if isTar(strings.ToLower(data.Assets[i].Name)) {
+ return data.Assets[i].BrowserDownloadURL, i, true
+ }
+ }
+ for _, i := range candidates {
+ if isZip(strings.ToLower(data.Assets[i].Name)) {
+ return data.Assets[i].BrowserDownloadURL, i, true
+ }
+ }
+ // fallback to first candidate
+ return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true
+ }
+
+ // Try platform matches first
+ if url, idx, ok := pickBest(platformIdx); ok {
+ // attempt to find checksum: prefer asset digest from API if present
+ if d := strings.TrimSpace(data.Assets[idx].Digest); d != "" {
+ dLower := strings.ToLower(d)
+ if strings.HasPrefix(dLower, "sha256:") {
+ hexpart := strings.TrimPrefix(dLower, "sha256:")
+ return url, hexpart, nil
+ }
+ // If digest already looks like a 64-hex, return it
+ if ok, _ := regexp.MatchString("(?i)^[a-f0-9]{64}$", dLower); ok {
+ return url, dLower, nil
+ }
+ }
+ // Look for checksum assets and verify by computing the asset's sha256.
+ for j, a := range data.Assets {
+ n := strings.ToLower(a.Name)
+ if strings.Contains(n, "sha256") ||
+ strings.Contains(n, "sha256sum") ||
+ strings.Contains(n, "checksums") ||
+ strings.HasSuffix(n, ".sha256") ||
+ strings.HasSuffix(n, ".sha256sum") {
+ resp2, err := getWithRetry(data.Assets[j].BrowserDownloadURL)
+ if err != nil {
+ continue
+ }
+ bs, err := io.ReadAll(resp2.Body)
+ resp2.Body.Close()
+ if err != nil {
+ continue
+ }
+ if h, ok := findHashInChecksumContent(bs, url); ok {
+ return url, h, nil
+ }
+ }
+ }
+ // No checksum found for the selected platform asset -> error
+ return "", "", fmt.Errorf("no checksum found for asset %s", url)
+ }
+
+ // No platform match — require explicit platform+arch; fail fast.
+ return "", "", fmt.Errorf("no release asset matching platform %q and arch %q", platform, arch)
+}
+
+func looksLikeDirectAssetURL(u string) bool {
+ if u == "" {
+ return false
+ }
+ lower := strings.ToLower(u)
+ if strings.HasSuffix(lower, ".zip") ||
+ strings.HasSuffix(lower, ".tar.gz") ||
+ strings.HasSuffix(lower, ".tgz") ||
+ strings.HasSuffix(lower, ".tar") {
+ return true
+ }
+ if strings.Contains(lower, "/releases/download/") {
+ return true
+ }
+ return false
+}
+
+func buildReleaseAPIURL(releaseURL string) string {
+ if releaseURL == "" {
+ return ""
+ }
+ if strings.Contains(releaseURL, "api.github.com") {
+ return releaseURL
+ }
+ u, err := url.Parse(releaseURL)
+ if err != nil {
+ return ""
+ }
+ if u.Host != "github.com" {
+ return ""
+ }
+ parts := strings.Split(strings.Trim(u.Path, "/"), "/")
+ if len(parts) < 2 {
+ return ""
+ }
+ owner := parts[0]
+ repo := parts[1]
+ // if tag specified
+ if len(parts) >= 5 && parts[2] == "releases" && parts[3] == "tag" {
+ tag := parts[4]
+ return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, tag)
+ }
+ // default to latest
+ return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
+}
+
+// NOTE: helper functions to compute SHA256 from URL/path were removed
+// after refactoring to stream the download and verify the checksum
+// during the single download to avoid double-transfer.
+
+// findHashInChecksumContent attempts to locate a 64-hex SHA256 in the
+// checksum file content that corresponds to assetURL. It returns the
+// found hash (lowercase) and true, or "", false if not found.
+func findHashInChecksumContent(bs []byte, assetURL string) (string, bool) {
+ s := strings.ToLower(string(bs))
+ var assetBase string
+ if u, err := url.Parse(assetURL); err == nil {
+ assetBase = strings.ToLower(filepath.Base(u.Path))
+ } else {
+ assetBase = strings.ToLower(filepath.Base(assetURL))
+ }
+ re := regexp.MustCompile(`(?i)\b([a-f0-9]{64})\b`)
+ // prefer a line containing the asset filename
+ for _, line := range strings.Split(s, "\n") {
+ if strings.Contains(line, assetBase) {
+ if m := re.FindString(line); m != "" {
+ return m, true
+ }
+ }
+ }
+ // fallback: if there's exactly one unique 64-hex value, return it
+ matches := re.FindAllString(s, -1)
+ uniq := map[string]struct{}{}
+ for _, m := range matches {
+ uniq[m] = struct{}{}
+ }
+ if len(uniq) == 1 {
+ for k := range uniq {
+ return k, true
+ }
+ }
+ return "", false
+}
+
+// progressWriter implements io.Writer and prints a simple progress
+// line to stderr while bytes are written. It is intended to be used
+// as one writer in an io.MultiWriter so we can stream-to-disk, compute
+// the sha256, and update the progress display in a single pass.
+type progressWriter struct {
+ total int64
+ written int64
+ last time.Time
+}
+
+func (pw *progressWriter) Write(p []byte) (int, error) {
+ n := len(p)
+ pw.written += int64(n)
+ now := time.Now()
+ if pw.last.IsZero() || now.Sub(pw.last) >= 200*time.Millisecond || (pw.total > 0 && pw.written == pw.total) {
+ pw.print()
+ pw.last = now
+ }
+ return n, nil
+}
+
+func (pw *progressWriter) print() {
+ if pw.total > 0 {
+ pct := float64(pw.written) * 100.0 / float64(pw.total)
+ fmt.Fprintf(os.Stderr, "\rDownloading: %s / %s (%.1f%%)", humanBytes(pw.written), humanBytes(pw.total), pct)
+ } else {
+ fmt.Fprintf(os.Stderr, "\rDownloading: %s", humanBytes(pw.written))
+ }
+}
+
+func (pw *progressWriter) Finish() {
+ pw.print()
+ fmt.Fprintln(os.Stderr, "")
+}
+
+func humanBytes(n int64) string {
+ f := float64(n)
+ const (
+ KB = 1024.0
+ MB = KB * 1024.0
+ GB = MB * 1024.0
+ )
+ switch {
+ case f >= GB:
+ return fmt.Sprintf("%.2f GB", f/GB)
+ case f >= MB:
+ return fmt.Sprintf("%.2f MB", f/MB)
+ case f >= KB:
+ return fmt.Sprintf("%.2f KB", f/KB)
+ default:
+ return fmt.Sprintf("%d B", n)
+ }
+}
+
+// archAliases returns common name variants for an architecture string
+// so we can match release asset names like "x86_64" vs Go's "amd64".
+// archAliases returns name variants for an architecture string.
+// If `arch` is empty or matches the local runtime.GOARCH, prefer the
+// compile-time architecture aliases provided by archAliasesForLocal
+// (implemented per-architecture via build tags). For other `arch`
+// values we use a small synonyms map.
+func archAliases(arch string) []string {
+ a := strings.ToLower(arch)
+ if syns, ok := archSynonyms[a]; ok {
+ return syns
+ }
+ return []string{a}
+}
+
+var archSynonyms = map[string][]string{
+ "amd64": {"amd64", "x86_64", "x64"},
+ "x86_64": {"amd64", "x86_64", "x64"},
+ "x64": {"amd64", "x86_64", "x64"},
+ "386": {"386", "x86"},
+ "x86": {"386", "x86"},
+ "arm64": {"arm64", "aarch64"},
+ "aarch64": {"arm64", "aarch64"},
+ "arm": {"arm"},
+}
+
+func extractArchive(archivePath, destDir string) error {
+ lower := strings.ToLower(archivePath)
+ if strings.HasSuffix(lower, ".zip") {
+ return extractZip(archivePath, destDir)
+ }
+ // treat .tar.gz and .tgz as gzip+tar
+ if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") {
+ return extractTarGz(archivePath, destDir)
+ }
+ if strings.HasSuffix(lower, ".tar") {
+ return extractTar(archivePath, destDir)
+ }
+ // fallback: try tar.gz
+ return extractTarGz(archivePath, destDir)
+}
+
+func extractZip(archivePath, destDir string) error {
+ r, err := zip.OpenReader(archivePath)
+ if err != nil {
+ return err
+ }
+ defer r.Close()
+ destClean := filepath.Clean(destDir)
+ for _, f := range r.File {
+ target := filepath.Clean(filepath.Join(destClean, f.Name))
+ if !strings.HasPrefix(target, destClean+string(os.PathSeparator)) && target != destClean {
+ return fmt.Errorf("path traversal detected: %s", f.Name)
+ }
+ if f.FileInfo().IsDir() {
+ if err := os.MkdirAll(target, f.FileInfo().Mode()); err != nil {
+ return err
+ }
+ continue
+ }
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ rc, err := f.Open()
+ if err != nil {
+ return err
+ }
+ out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, f.FileInfo().Mode())
+ if err != nil {
+ rc.Close()
+ return err
+ }
+ if _, err := io.Copy(out, rc); err != nil {
+ rc.Close()
+ out.Close()
+ return err
+ }
+ rc.Close()
+ out.Close()
+ }
+ return nil
+}
+
+func extractTarGz(archivePath, destDir string) error {
+ f, err := os.Open(archivePath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ gzr, err := gzip.NewReader(f)
+ if err != nil {
+ return err
+ }
+ defer gzr.Close()
+ tr := tar.NewReader(gzr)
+ return extractTarFromReader(tr, destDir)
+}
+
+func extractTar(archivePath, destDir string) error {
+ f, err := os.Open(archivePath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ tr := tar.NewReader(f)
+ return extractTarFromReader(tr, destDir)
+}
+
+// extractTarFromReader contains logic common to extracting entries from a
+// tar.Reader and is used by both extractTarGz and extractTar to avoid
+// duplicated code (golangci-lint: dupl).
+func extractTarFromReader(tr *tar.Reader, destDir string) error {
+ for {
+ hdr, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return err
+ }
+ target := filepath.Clean(filepath.Join(filepath.Clean(destDir), hdr.Name))
+ if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) &&
+ target != filepath.Clean(destDir) {
+ return fmt.Errorf("path traversal detected: %s", hdr.Name)
+ }
+ switch hdr.Typeflag {
+ case tar.TypeDir:
+ if err := os.MkdirAll(target, 0o755); err != nil {
+ return err
+ }
+ case tar.TypeReg:
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode))
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(out, tr); err != nil {
+ out.Close()
+ return err
+ }
+ out.Close()
+ }
+ }
+ return nil
+}
+
+func findBinaryInDir(dir, programName string) (string, error) {
+ wanted := []string{programName}
+ if runtime.GOOS == "windows" {
+ wanted = append([]string{programName + ".exe"}, wanted...)
+ } else {
+ // also accept programs with .exe in archives targeting windows
+ wanted = append(wanted, programName+".exe")
+ }
+
+ var found string
+ if err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
+ if err != nil || found != "" {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ base := filepath.Base(p)
+ for _, w := range wanted {
+ if base == w {
+ found = p
+ return io.EOF // use EOF to stop walking early
+ }
+ }
+ return nil
+ }); err != nil && err != io.EOF {
+ return "", err
+ }
+ if found == "" {
+ return "", fmt.Errorf("binary %q not found in archive", programName)
+ }
+ return found, nil
+}
+
+// NewUpdateCommand returns a cobra command that triggers UpdateSelfFromRelease.
+func NewUpdateCommand(binaryName string) *cobra.Command {
+ var urlStr, platform, arch string
+ cmd := &cobra.Command{
+ Use: "update",
+ Short: "Check and apply updates from GitHub releases",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if platform == "" {
+ platform = runtime.GOOS
+ }
+ if arch == "" {
+ arch = runtime.GOARCH
+ }
+ fmt.Printf("Current version: %s\n", config.FormatVersion())
+ if err := UpdateSelfFromRelease(urlStr, platform, arch, binaryName); err != nil {
+ return err
+ }
+ fmt.Println("Update applied; restart to use the new version.")
+ return nil
+ },
+ }
+ cmd.Flags().StringVarP(&urlStr, "url", "u", "", "Direct URL to download release asset or release page")
+ cmd.Flags().StringVar(&platform, "platform", "", "Target platform (default: runtime.GOOS)")
+ cmd.Flags().StringVar(&arch, "arch", "", "Target arch (default: runtime.GOARCH)")
+ return cmd
+}
diff --git a/pkg/updater/updater_test.go b/pkg/updater/updater_test.go
new file mode 100644
index 000000000..75159af12
--- /dev/null
+++ b/pkg/updater/updater_test.go
@@ -0,0 +1,415 @@
+package updater
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "bytes"
+ "compress/gzip"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// matchesMagic checks whether the file at path looks like a platform binary
+// by inspecting magic bytes (ELF for linux, MZ for windows).
+func matchesMagic(path, platform string) (bool, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return false, err
+ }
+ defer f.Close()
+ buf := make([]byte, 4)
+ n, err := f.Read(buf)
+ if err != nil && err != io.EOF {
+ return false, err
+ }
+ if n >= 4 && buf[0] == 0x7f && buf[1] == 'E' && buf[2] == 'L' && buf[3] == 'F' {
+ return strings.Contains(platform, "linux"), nil
+ }
+ if n >= 2 && buf[0] == 'M' && buf[1] == 'Z' {
+ return strings.Contains(platform, "windows"), nil
+ }
+ return false, nil
+}
+
+type testReleaseAsset struct {
+ Name string `json:"name"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+ Digest string `json:"digest,omitempty"`
+}
+
+type testReleasePayload struct {
+ TagName string `json:"tag_name"`
+ Assets []testReleaseAsset `json:"assets"`
+}
+
+const testReleaseAPIPath = "/api.github.com/repos/sipeed/picoclaw/releases/latest"
+
+// TestDownloadAndExtractRelease_IntegrationLatestRelease downloads the latest
+// public release for a single platform as an opt-in smoke test.
+func TestDownloadAndExtractRelease_IntegrationLatestRelease(t *testing.T) {
+ if os.Getenv("PICOCLAW_INTEGRATION_TESTS") == "" {
+ t.Skip("skipping integration test (set PICOCLAW_INTEGRATION_TESTS=1 to enable)")
+ }
+ if testing.Short() {
+ t.Skip("skipping integration test in short mode")
+ }
+
+ const platform = "linux"
+ const arch = "amd64"
+ apiURL := GetProdReleaseAPIURL()
+ assetURL, checksum, err := findAssetInfo(apiURL, platform, arch)
+ if err != nil {
+ t.Fatalf("findAssetInfo failed for %s/%s: %v", platform, arch, err)
+ }
+ t.Logf("asset URL: %s checksum: %s", assetURL, checksum)
+
+ dir, err := DownloadAndExtractRelease(apiURL, platform, arch)
+ if err != nil {
+ t.Fatalf("DownloadAndExtractRelease failed for %s/%s: %v", platform, arch, err)
+ }
+ defer os.RemoveAll(dir)
+
+ var found bool
+ _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ info, err := d.Info()
+ if err != nil {
+ return err
+ }
+ if info.Size() < 64 {
+ return nil
+ }
+ ok, err := matchesMagic(path, platform)
+ if err != nil {
+ return err
+ }
+ if ok {
+ found = true
+ t.Logf("found artifact: %s (size=%d)", path, info.Size())
+ }
+ return nil
+ })
+ if !found {
+ t.Fatalf("no binary-like artifact found for %s/%s", platform, arch)
+ }
+}
+
+func TestFindAssetInfo_SelectsPreferredAsset(t *testing.T) {
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case testReleaseAPIPath:
+ writeReleasePayload(w, testReleasePayload{
+ TagName: "v0.2.6",
+ Assets: []testReleaseAsset{
+ {
+ Name: "picoclaw_Linux_x86_64.zip",
+ BrowserDownloadURL: server.URL + "/assets/picoclaw_Linux_x86_64.zip",
+ Digest: "sha256:" + strings.Repeat("1", 64),
+ },
+ {
+ Name: "picoclaw_Linux_x86_64.tar.gz",
+ BrowserDownloadURL: server.URL + "/assets/picoclaw_Linux_x86_64.tar.gz",
+ Digest: "sha256:" + strings.Repeat("2", 64),
+ },
+ {
+ Name: "picoclaw_Windows_x86_64.zip",
+ BrowserDownloadURL: server.URL + "/assets/picoclaw_Windows_x86_64.zip",
+ Digest: "sha256:" + strings.Repeat("3", 64),
+ },
+ {
+ Name: "picoclaw_Windows_arm64.zip",
+ BrowserDownloadURL: server.URL + "/assets/picoclaw_Windows_arm64.zip",
+ Digest: "sha256:" + strings.Repeat("4", 64),
+ },
+ },
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ withTestHTTPClient(t, server.Client())
+
+ tests := []struct {
+ name string
+ platform string
+ arch string
+ wantURL string
+ wantChecksum string
+ }{
+ {
+ name: "linux prefers tar.gz over zip",
+ platform: "linux",
+ arch: "amd64",
+ wantURL: server.URL + "/assets/picoclaw_Linux_x86_64.tar.gz",
+ wantChecksum: strings.Repeat("2", 64),
+ },
+ {
+ name: "windows amd64 matches x86_64 zip",
+ platform: "windows",
+ arch: "amd64",
+ wantURL: server.URL + "/assets/picoclaw_Windows_x86_64.zip",
+ wantChecksum: strings.Repeat("3", 64),
+ },
+ {
+ name: "windows arm64 matches arm64 zip",
+ platform: "windows",
+ arch: "arm64",
+ wantURL: server.URL + "/assets/picoclaw_Windows_arm64.zip",
+ wantChecksum: strings.Repeat("4", 64),
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ gotURL, gotChecksum, err := findAssetInfo(server.URL+testReleaseAPIPath, tc.platform, tc.arch)
+ if err != nil {
+ t.Fatalf(
+ "findAssetInfo(%q, %q, %q) error: %v",
+ server.URL+testReleaseAPIPath,
+ tc.platform,
+ tc.arch,
+ err,
+ )
+ }
+ if gotURL != tc.wantURL {
+ t.Fatalf("assetURL = %q, want %q", gotURL, tc.wantURL)
+ }
+ if gotChecksum != tc.wantChecksum {
+ t.Fatalf("checksum = %q, want %q", gotChecksum, tc.wantChecksum)
+ }
+ })
+ }
+}
+
+func TestFindAssetInfo_UsesChecksumAssetWhenDigestMissing(t *testing.T) {
+ const checksum = "77b564f36da6d1e02169d0ecc837728eecb9ef983c317d9186ac9651798b924c"
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case testReleaseAPIPath:
+ writeReleasePayload(w, testReleasePayload{
+ TagName: "v0.2.6",
+ Assets: []testReleaseAsset{
+ {
+ Name: "picoclaw_Windows_x86_64.zip",
+ BrowserDownloadURL: server.URL + "/assets/picoclaw_Windows_x86_64.zip",
+ },
+ {
+ Name: "checksums.txt",
+ BrowserDownloadURL: server.URL + "/assets/checksums.txt",
+ },
+ },
+ })
+ case "/assets/checksums.txt":
+ _, _ = io.WriteString(w, checksum+" picoclaw_Windows_x86_64.zip\n")
+ case "/assets/picoclaw_Windows_x86_64.zip":
+ w.WriteHeader(http.StatusInternalServerError)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ withTestHTTPClient(t, server.Client())
+
+ gotURL, gotChecksum, err := findAssetInfo(server.URL+testReleaseAPIPath, "windows", "amd64")
+ if err != nil {
+ t.Fatalf("findAssetInfo returned error: %v", err)
+ }
+ if gotURL != server.URL+"/assets/picoclaw_Windows_x86_64.zip" {
+ t.Fatalf("assetURL = %q, want %q", gotURL, server.URL+"/assets/picoclaw_Windows_x86_64.zip")
+ }
+ if gotChecksum != checksum {
+ t.Fatalf("checksum = %q, want %q", gotChecksum, checksum)
+ }
+}
+
+func TestDownloadAndExtractRelease_ExtractsTarGz(t *testing.T) {
+ tarGzContent := buildTestTarGz(t, map[string]string{
+ "picoclaw_Linux_x86_64/picoclaw": "test linux binary payload",
+ })
+ sum := sha256.Sum256(tarGzContent)
+ checksum := hex.EncodeToString(sum[:])
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case testReleaseAPIPath:
+ writeReleasePayload(w, testReleasePayload{
+ TagName: "v0.2.6",
+ Assets: []testReleaseAsset{
+ {
+ Name: "picoclaw_Linux_x86_64.tar.gz",
+ BrowserDownloadURL: server.URL + "/assets/picoclaw_Linux_x86_64.tar.gz",
+ Digest: "sha256:" + checksum,
+ },
+ },
+ })
+ case "/assets/picoclaw_Linux_x86_64.tar.gz":
+ w.Header().Set("Content-Type", "application/gzip")
+ _, _ = w.Write(tarGzContent)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ withTestHTTPClient(t, server.Client())
+
+ dir, err := DownloadAndExtractRelease(server.URL+testReleaseAPIPath, "linux", "amd64")
+ if err != nil {
+ t.Fatalf("DownloadAndExtractRelease returned error: %v", err)
+ }
+ defer os.RemoveAll(dir)
+
+ binPath, err := findBinaryInDir(dir, "picoclaw")
+ if err != nil {
+ t.Fatalf("findBinaryInDir returned error: %v", err)
+ }
+
+ bs, err := os.ReadFile(binPath)
+ if err != nil {
+ t.Fatalf("ReadFile extracted asset: %v", err)
+ }
+ if got := string(bs); got != "test linux binary payload" {
+ t.Fatalf("extracted content = %q, want %q", got, "test linux binary payload")
+ }
+}
+
+func TestDownloadAndExtractRelease_RetriesTransientAssetFailure(t *testing.T) {
+ zipContent := buildTestZip(t, map[string]string{
+ "picoclaw.exe": "test windows binary payload",
+ })
+ sum := sha256.Sum256(zipContent)
+ checksum := hex.EncodeToString(sum[:])
+
+ var assetAttempts int
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api.github.com/repos/sipeed/picoclaw/releases/latest":
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(
+ w,
+ `{"tag_name":"v0.2.6","assets":[{"name":"picoclaw_Windows_x86_64.zip","browser_download_url":%q,"digest":"sha256:%s"}]}`,
+ server.URL+"/assets/picoclaw_Windows_x86_64.zip",
+ checksum,
+ )
+ case "/assets/picoclaw_Windows_x86_64.zip":
+ assetAttempts++
+ if assetAttempts == 1 {
+ w.WriteHeader(http.StatusGatewayTimeout)
+ return
+ }
+ w.Header().Set("Content-Type", "application/zip")
+ _, _ = w.Write(zipContent)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ withTestHTTPClient(t, server.Client())
+
+ dir, err := DownloadAndExtractRelease(
+ server.URL+"/api.github.com/repos/sipeed/picoclaw/releases/latest",
+ "windows",
+ "amd64",
+ )
+ if err != nil {
+ t.Fatalf("DownloadAndExtractRelease returned error: %v", err)
+ }
+ defer os.RemoveAll(dir)
+
+ if assetAttempts != 2 {
+ t.Fatalf("asset attempts = %d, want 2", assetAttempts)
+ }
+
+ bs, err := os.ReadFile(filepath.Join(dir, "picoclaw.exe"))
+ if err != nil {
+ t.Fatalf("ReadFile extracted asset: %v", err)
+ }
+ if got := string(bs); got != "test windows binary payload" {
+ t.Fatalf("extracted content = %q, want %q", got, "test windows binary payload")
+ }
+}
+
+func buildTestZip(t *testing.T, files map[string]string) []byte {
+ t.Helper()
+
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ for name, content := range files {
+ w, err := zw.Create(name)
+ if err != nil {
+ t.Fatalf("Create zip entry %q: %v", name, err)
+ }
+ if _, err := io.WriteString(w, content); err != nil {
+ t.Fatalf("Write zip entry %q: %v", name, err)
+ }
+ }
+ if err := zw.Close(); err != nil {
+ t.Fatalf("Close zip writer: %v", err)
+ }
+ return buf.Bytes()
+}
+
+func buildTestTarGz(t *testing.T, files map[string]string) []byte {
+ t.Helper()
+
+ var buf bytes.Buffer
+ gzw := gzip.NewWriter(&buf)
+ tw := tar.NewWriter(gzw)
+
+ for name, content := range files {
+ if err := tw.WriteHeader(&tar.Header{
+ Name: name,
+ Mode: 0o755,
+ Size: int64(len(content)),
+ }); err != nil {
+ t.Fatalf("Write tar header %q: %v", name, err)
+ }
+ if _, err := io.WriteString(tw, content); err != nil {
+ t.Fatalf("Write tar entry %q: %v", name, err)
+ }
+ }
+ if err := tw.Close(); err != nil {
+ t.Fatalf("Close tar writer: %v", err)
+ }
+ if err := gzw.Close(); err != nil {
+ t.Fatalf("Close gzip writer: %v", err)
+ }
+ return buf.Bytes()
+}
+
+func writeReleasePayload(w http.ResponseWriter, payload testReleasePayload) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(payload)
+}
+
+func withTestHTTPClient(t *testing.T, client *http.Client) {
+ t.Helper()
+
+ origClient := httpClient
+ httpClient = client
+ httpClient.Timeout = 5 * time.Second
+ t.Cleanup(func() {
+ httpClient = origClient
+ })
+}
diff --git a/pkg/utils/bm25.go b/pkg/utils/bm25.go
index 95c63f0e3..f8b9f6882 100644
--- a/pkg/utils/bm25.go
+++ b/pkg/utils/bm25.go
@@ -29,18 +29,18 @@ const (
DefaultBM25B = 0.75
)
-// BM25Engine is a query-time BM25 search engine over a generic corpus.
+// BM25Engine is a BM25 search engine over a generic corpus.
// T is the document type; the caller supplies a TextFunc that extracts the
// searchable text from each document.
//
-// The engine is stateless between queries: no caching, no invalidation logic.
-// All indexing work is performed inside Search() on every call, making it
-// safe to use on corpora that change frequently.
+// The engine precomputes its index once at construction time and reuses it for
+// subsequent searches. If the corpus content changes, construct a new engine.
type BM25Engine[T any] struct {
corpus []T
textFunc func(T) string
k1 float64
b float64
+ index *bm25Index
}
// BM25Option is a functional option to configure a BM25Engine.
@@ -51,6 +51,17 @@ type bm25Config struct {
b float64
}
+type bm25Index struct {
+ entries []bm25DocEntry
+ idf map[string]float32
+ docLenNorm []float32
+ posting map[string][]int32
+}
+
+type bm25DocEntry struct {
+ tf map[string]uint32
+}
+
// WithK1 overrides the term-frequency saturation constant (default 1.2).
func WithK1(k1 float64) BM25Option {
return func(c *bm25Config) { c.k1 = k1 }
@@ -74,12 +85,14 @@ func NewBM25Engine[T any](corpus []T, textFunc func(T) string, opts ...BM25Optio
for _, o := range opts {
o(&cfg)
}
- return &BM25Engine[T]{
+ engine := &BM25Engine[T]{
corpus: corpus,
textFunc: textFunc,
k1: cfg.k1,
b: cfg.b,
}
+ engine.index = buildBM25Index(corpus, textFunc, cfg.k1, cfg.b)
+ return engine
}
// BM25Result is a single ranked result from a Search call.
@@ -91,9 +104,8 @@ type BM25Result[T any] struct {
// Search ranks the corpus against query and returns the top-k results.
// Returns an empty slice (not nil) when there are no matches.
//
-// Complexity: O(N×L) for indexing + O(|Q|×avgPostingLen) for scoring,
-// where N = corpus size, L = average document length, Q = query terms.
-// Top-k extraction uses a fixed-size min-heap: O(candidates × log k).
+// Complexity: O(|Q|×avgPostingLen + candidates × log k) per search after the
+// one-time indexing work performed by NewBM25Engine.
func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
if topK <= 0 {
return []BM25Result[T]{}
@@ -104,78 +116,24 @@ func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
return []BM25Result[T]{}
}
- N := len(e.corpus)
- if N == 0 {
+ if len(e.corpus) == 0 || e.index == nil {
return []BM25Result[T]{}
}
- // Step 1: build per-document tf + raw doc lengths
- type docEntry struct {
- tf map[string]uint32
- rawLen int
- }
-
- entries := make([]docEntry, N)
- df := make(map[string]int, 64)
- totalLen := 0
-
- for i, doc := range e.corpus {
- tokens := bm25Tokenize(e.textFunc(doc))
- totalLen += len(tokens)
-
- tf := make(map[string]uint32, len(tokens))
- for _, t := range tokens {
- tf[t]++
- }
- // df: each term counts once per document (iterate the map, keys are unique)
- for t := range tf {
- df[t]++
- }
-
- entries[i] = docEntry{tf: tf, rawLen: len(tokens)}
- }
-
- avgDocLen := float64(totalLen) / float64(N)
-
- // Step 2: pre-compute IDF and per-doc length normalization
- // IDF (Robertson smoothing): log( (N - df(t) + 0.5) / (df(t) + 0.5) + 1 )
- idf := make(map[string]float32, len(df))
- for term, freq := range df {
- idf[term] = float32(math.Log(
- (float64(N)-float64(freq)+0.5)/(float64(freq)+0.5) + 1,
- ))
- }
-
- // docLenNorm[i] = k1 * (1 - b + b * |doc_i| / avgDocLen)
- // Stored as float32 — sufficient precision for ranking.
- docLenNorm := make([]float32, N)
- for i, entry := range entries {
- docLenNorm[i] = float32(e.k1 * (1 - e.b + e.b*float64(entry.rawLen)/avgDocLen))
- }
-
- // Step 3: build inverted index (posting lists)
- // Iterate the tf map directly — map keys are already unique, no seen-set needed.
- posting := make(map[string][]int32, len(df))
- for i, entry := range entries {
- for term := range entry.tf {
- posting[term] = append(posting[term], int32(i))
- }
- }
-
// Step 4: score via posting lists
// Deduplicate query terms to avoid double-weighting the same term.
unique := bm25Dedupe(queryTerms)
scores := make(map[int32]float32)
for _, term := range unique {
- termIDF, ok := idf[term]
+ termIDF, ok := e.index.idf[term]
if !ok {
continue // term not in vocabulary → zero contribution
}
- for _, docID := range posting[term] {
- freq := float32(entries[docID].tf[term])
+ for _, docID := range e.index.posting[term] {
+ freq := float32(e.index.entries[docID].tf[term])
// TF_norm = freq * (k1+1) / (freq + docLenNorm)
- tfNorm := freq * float32(e.k1+1) / (freq + docLenNorm[docID])
+ tfNorm := freq * float32(e.k1+1) / (freq + e.index.docLenNorm[docID])
scores[docID] += termIDF * tfNorm
}
}
@@ -212,6 +170,65 @@ func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
return out
}
+func buildBM25Index[T any](corpus []T, textFunc func(T) string, k1, b float64) *bm25Index {
+ N := len(corpus)
+ if N == 0 {
+ return nil
+ }
+
+ entries := make([]bm25DocEntry, N)
+ rawLens := make([]int, N)
+ df := make(map[string]int, 64)
+ totalLen := 0
+
+ for i, doc := range corpus {
+ tokens := bm25Tokenize(textFunc(doc))
+ totalLen += len(tokens)
+ rawLens[i] = len(tokens)
+
+ tf := make(map[string]uint32, len(tokens))
+ for _, t := range tokens {
+ tf[t]++
+ }
+ for term := range tf {
+ df[term]++
+ }
+
+ entries[i] = bm25DocEntry{tf: tf}
+ }
+
+ avgDocLen := float64(totalLen) / float64(N)
+ if avgDocLen == 0 {
+ avgDocLen = 1
+ }
+
+ idf := make(map[string]float32, len(df))
+ for term, freq := range df {
+ idf[term] = float32(math.Log(
+ (float64(N)-float64(freq)+0.5)/(float64(freq)+0.5) + 1,
+ ))
+ }
+
+ docLenNorm := make([]float32, N)
+ for i, rawLen := range rawLens {
+ docLenNorm[i] = float32(k1 * (1 - b + b*float64(rawLen)/avgDocLen))
+ }
+
+ posting := make(map[string][]int32, len(df))
+ for i, entry := range entries {
+ for term := range entry.tf {
+ posting[term] = append(posting[term], int32(i))
+ }
+ }
+
+ return &bm25Index{
+ entries: entries,
+ idf: idf,
+ docLenNorm: docLenNorm,
+ posting: posting,
+ }
+}
+
// bm25Tokenize splits s into lowercase tokens, stripping edge punctuation.
func bm25Tokenize(s string) []string {
raw := strings.Fields(strings.ToLower(s))
diff --git a/pkg/utils/bm25_test.go b/pkg/utils/bm25_test.go
index 4bc85b246..216fe733d 100644
--- a/pkg/utils/bm25_test.go
+++ b/pkg/utils/bm25_test.go
@@ -1,7 +1,9 @@
package utils
import (
+ "fmt"
"reflect"
+ "strings"
"testing"
)
@@ -173,3 +175,61 @@ func TestBM25Search_SortingStability(t *testing.T) {
}
}
}
+
+func BenchmarkBM25Search_ReusedIndex(b *testing.B) {
+ corpus := benchmarkBM25Corpus(2000)
+ engine := NewBM25Engine(corpus, extractText)
+ query := "hardware gpio i2c sensor controller latency"
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ results := engine.Search(query, 10)
+ if len(results) == 0 {
+ b.Fatal("expected non-empty results")
+ }
+ }
+}
+
+func BenchmarkBM25Search_RebuildEachTime(b *testing.B) {
+ corpus := benchmarkBM25Corpus(2000)
+ query := "hardware gpio i2c sensor controller latency"
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ engine := NewBM25Engine(corpus, extractText)
+ results := engine.Search(query, 10)
+ if len(results) == 0 {
+ b.Fatal("expected non-empty results")
+ }
+ }
+}
+
+func benchmarkBM25Corpus(size int) []testDoc {
+ corpus := make([]testDoc, size)
+ topics := []string{
+ "hardware gpio pwm adc sensor controller latency throughput",
+ "telegram markdown parser message escape formatting bot command",
+ "jsonl memory session history storage append compact recovery",
+ "openai provider routing agent tool search registry hidden tools",
+ "i2c spi uart serial device bus address transfer clock",
+ }
+
+ for i := range corpus {
+ topic := topics[i%len(topics)]
+ corpus[i] = testDoc{
+ ID: i,
+ Text: fmt.Sprintf(
+ "doc %d %s repeated repeated %s variant-%d %s",
+ i,
+ topic,
+ topic,
+ i%17,
+ strings.Repeat("token ", (i%7)+1),
+ ),
+ }
+ }
+
+ return corpus
+}
diff --git a/pkg/utils/http_retry.go b/pkg/utils/http_retry.go
index 135ea0ef5..514f9781b 100644
--- a/pkg/utils/http_retry.go
+++ b/pkg/utils/http_retry.go
@@ -4,12 +4,16 @@ import (
"context"
"fmt"
"net/http"
+ "strconv"
"time"
)
const maxRetries = 3
-var retryDelayUnit = time.Second
+var (
+ retryDelayUnit = time.Second
+ maxRetrySleepDuration = 1 * time.Minute
+)
func shouldRetry(statusCode int) bool {
return statusCode == http.StatusTooManyRequests ||
@@ -36,7 +40,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response,
}
if i < maxRetries-1 {
- if err = sleepWithCtx(req.Context(), retryDelayUnit*time.Duration(i+1)); err != nil {
+ if err = sleepWithCtx(req.Context(), retryDelayForAttempt(resp, i)); err != nil {
if resp != nil {
resp.Body.Close()
}
@@ -47,6 +51,57 @@ func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response,
return resp, err
}
+func retryDelayForAttempt(resp *http.Response, attempt int) time.Duration {
+ fallback := retryDelayUnit * time.Duration(attempt+1)
+ if resp == nil || resp.StatusCode != http.StatusTooManyRequests {
+ return clampRetryDelay(fallback)
+ }
+
+ retryAfter := resp.Header.Get("Retry-After")
+ if retryAfter == "" {
+ return clampRetryDelay(fallback)
+ }
+
+ if delay, ok := numericRetryAfterDelay(retryAfter); ok {
+ return delay
+ }
+
+ if when, err := http.ParseTime(retryAfter); err == nil {
+ delay := time.Until(when)
+ if serverDate, err := http.ParseTime(resp.Header.Get("Date")); err == nil {
+ delay = when.Sub(serverDate)
+ }
+ if delay < 0 {
+ return 0
+ }
+ return clampRetryDelay(delay)
+ }
+
+ return clampRetryDelay(fallback)
+}
+
+func numericRetryAfterDelay(retryAfter string) (time.Duration, bool) {
+ seconds, err := strconv.ParseInt(retryAfter, 10, 64)
+ if err != nil || seconds < 0 {
+ return 0, false
+ }
+ maxSeconds := int64(maxRetrySleepDuration / time.Second)
+ if seconds > maxSeconds {
+ return maxRetrySleepDuration, true
+ }
+ return clampRetryDelay(time.Duration(seconds) * time.Second), true
+}
+
+func clampRetryDelay(delay time.Duration) time.Duration {
+ if delay <= 0 {
+ return 0
+ }
+ if delay > maxRetrySleepDuration {
+ return maxRetrySleepDuration
+ }
+ return delay
+}
+
func sleepWithCtx(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
diff --git a/pkg/utils/http_retry_test.go b/pkg/utils/http_retry_test.go
index d64cd5eda..4d6021ff7 100644
--- a/pkg/utils/http_retry_test.go
+++ b/pkg/utils/http_retry_test.go
@@ -80,6 +80,81 @@ func TestDoRequestWithRetry(t *testing.T) {
}
}
+func TestDoRequestWithRetry_RetryAfter429Honored(t *testing.T) {
+ retryDelayUnit = 10 * time.Millisecond
+ t.Cleanup(func() { retryDelayUnit = time.Second })
+
+ attempts := 0
+ var firstAttemptAt time.Time
+ var secondAttemptAt time.Time
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ attempts++
+ if attempts == 1 {
+ firstAttemptAt = time.Now()
+ w.Header().Set("Retry-After", "1")
+ w.WriteHeader(http.StatusTooManyRequests)
+ return
+ }
+ if attempts == 2 {
+ secondAttemptAt = time.Now()
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ client := &http.Client{Timeout: 5 * time.Second}
+ req, err := http.NewRequest(http.MethodGet, server.URL, nil)
+ require.NoError(t, err)
+
+ resp, err := DoRequestWithRetry(client, req)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ resp.Body.Close()
+ require.Equal(t, 2, attempts)
+
+ assert.GreaterOrEqual(t, secondAttemptAt.Sub(firstAttemptAt), 900*time.Millisecond)
+}
+
+func TestDoRequestWithRetry_RetryAfter429InvalidFallsBack(t *testing.T) {
+ retryDelayUnit = 50 * time.Millisecond
+ t.Cleanup(func() { retryDelayUnit = time.Second })
+
+ attempts := 0
+ var firstAttemptAt time.Time
+ var secondAttemptAt time.Time
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ attempts++
+ if attempts == 1 {
+ firstAttemptAt = time.Now()
+ w.Header().Set("Retry-After", "invalid")
+ w.WriteHeader(http.StatusTooManyRequests)
+ return
+ }
+ if attempts == 2 {
+ secondAttemptAt = time.Now()
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ client := &http.Client{Timeout: 5 * time.Second}
+ req, err := http.NewRequest(http.MethodGet, server.URL, nil)
+ require.NoError(t, err)
+
+ resp, err := DoRequestWithRetry(client, req)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ resp.Body.Close()
+ require.Equal(t, 2, attempts)
+
+ assert.GreaterOrEqual(t, secondAttemptAt.Sub(firstAttemptAt), 45*time.Millisecond)
+ assert.Less(t, secondAttemptAt.Sub(firstAttemptAt), 500*time.Millisecond)
+}
+
func TestDoRequestWithRetry_ContextCancel(t *testing.T) {
// Use a long retry delay so cancellation always hits during sleepWithCtx.
retryDelayUnit = 10 * time.Second
@@ -204,3 +279,87 @@ func TestDoRequestWithRetry_Delay(t *testing.T) {
assert.GreaterOrEqual(t, delays[2], time.Millisecond)
}
+
+func TestRetryDelayForAttempt_DateRetryAfterUsesResponseDateHeader(t *testing.T) {
+ maxRetrySleepDuration = time.Minute
+ t.Cleanup(func() { maxRetrySleepDuration = time.Minute })
+
+ serverDate := time.Date(2000, 1, 2, 15, 4, 5, 0, time.UTC)
+ retryAfterAt := serverDate.Add(10 * time.Second)
+ resp := &http.Response{
+ StatusCode: http.StatusTooManyRequests,
+ Header: http.Header{
+ "Retry-After": []string{retryAfterAt.Format(http.TimeFormat)},
+ "Date": []string{serverDate.Format(http.TimeFormat)},
+ },
+ }
+
+ assert.Equal(t, 10*time.Second, retryDelayForAttempt(resp, 0))
+}
+
+func TestRetryDelayForAttempt_DateRetryAfterInvalidOrMissingDateFallsBackSafely(t *testing.T) {
+ maxRetrySleepDuration = 30 * time.Second
+ t.Cleanup(func() { maxRetrySleepDuration = time.Minute })
+
+ retryAfterAt := time.Now().UTC().Add(3 * time.Second).Format(http.TimeFormat)
+ testcases := []struct {
+ name string
+ header http.Header
+ }{
+ {
+ name: "invalid-date-header",
+ header: http.Header{
+ "Retry-After": []string{retryAfterAt},
+ "Date": []string{"invalid-date"},
+ },
+ },
+ {
+ name: "missing-date-header",
+ header: http.Header{
+ "Retry-After": []string{retryAfterAt},
+ },
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ resp := &http.Response{
+ StatusCode: http.StatusTooManyRequests,
+ Header: tc.header,
+ }
+
+ delay := retryDelayForAttempt(resp, 0)
+ assert.Greater(t, delay, time.Duration(0))
+ assert.GreaterOrEqual(t, delay, 1500*time.Millisecond)
+ assert.LessOrEqual(t, delay, 5*time.Second)
+ })
+ }
+}
+
+func TestRetryDelayForAttempt_RetryAfterIsCapped(t *testing.T) {
+ maxRetrySleepDuration = 2 * time.Second
+ t.Cleanup(func() { maxRetrySleepDuration = time.Minute })
+
+ resp := &http.Response{
+ StatusCode: http.StatusTooManyRequests,
+ Header: http.Header{
+ "Retry-After": []string{"999999"},
+ },
+ }
+
+ assert.Equal(t, 2*time.Second, retryDelayForAttempt(resp, 0))
+}
+
+func TestRetryDelayForAttempt_RetryAfterNumericOverflowStillCaps(t *testing.T) {
+ maxRetrySleepDuration = 2 * time.Second
+ t.Cleanup(func() { maxRetrySleepDuration = time.Minute })
+
+ resp := &http.Response{
+ StatusCode: http.StatusTooManyRequests,
+ Header: http.Header{
+ "Retry-After": []string{"9223372036854775807"},
+ },
+ }
+
+ assert.Equal(t, 2*time.Second, retryDelayForAttempt(resp, 0))
+}
diff --git a/pkg/utils/tool_feedback.go b/pkg/utils/tool_feedback.go
new file mode 100644
index 000000000..1834d7f78
--- /dev/null
+++ b/pkg/utils/tool_feedback.go
@@ -0,0 +1,90 @@
+package utils
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+const ToolFeedbackContinuationHint = "Continuing the current task."
+
+func FormatArgsJSON(args map[string]any, prettyPrint, disableEscapeHTML bool) string {
+ // Normalize nil to empty map for consistent output
+ if args == nil {
+ args = map[string]any{}
+ }
+
+ var buf bytes.Buffer
+ enc := json.NewEncoder(&buf)
+ if prettyPrint {
+ enc.SetIndent("", " ")
+ }
+ if disableEscapeHTML {
+ enc.SetEscapeHTML(false)
+ }
+ if err := enc.Encode(args); err != nil {
+ // Fallback to fmt.Sprintf to preserve visibility of problematic args
+ return fmt.Sprintf("%v", args)
+ }
+ return strings.TrimSpace(buf.String())
+}
+
+// FormatToolFeedbackMessage renders a tool feedback message for chat channels.
+// It keeps the tool name on the first line for animation and can include both
+// a human explanation and the serialized tool arguments in the body.
+func FormatToolFeedbackMessage(toolName, explanation, argsPreview string) string {
+ toolName = strings.TrimSpace(toolName)
+ explanation = strings.TrimSpace(explanation)
+ argsPreview = strings.TrimSpace(argsPreview)
+
+ bodyLines := make([]string, 0, 2)
+ if explanation != "" {
+ bodyLines = append(bodyLines, explanation)
+ }
+ if argsPreview != "" {
+ bodyLines = append(bodyLines, "```json\n"+argsPreview+"\n```")
+ }
+ body := strings.Join(bodyLines, "\n")
+
+ if toolName == "" {
+ return body
+ }
+ if body == "" {
+ return fmt.Sprintf("\U0001f527 `%s`", toolName)
+ }
+
+ return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, body)
+}
+
+// FitToolFeedbackMessage keeps tool feedback within a single outbound message.
+// It preserves the first line when possible and truncates the explanation body
+// instead of letting the message be split into multiple chunks.
+func FitToolFeedbackMessage(content string, maxLen int) string {
+ content = strings.TrimSpace(content)
+ if content == "" || maxLen <= 0 {
+ return ""
+ }
+ if len([]rune(content)) <= maxLen {
+ return content
+ }
+
+ firstLine, rest, hasRest := strings.Cut(content, "\n")
+ firstLine = strings.TrimSpace(firstLine)
+ rest = strings.TrimSpace(rest)
+
+ if !hasRest || rest == "" {
+ return Truncate(firstLine, maxLen)
+ }
+
+ if len([]rune(firstLine)) >= maxLen {
+ return Truncate(firstLine, maxLen)
+ }
+
+ remaining := maxLen - len([]rune(firstLine)) - 1
+ if remaining <= 0 {
+ return Truncate(firstLine, maxLen)
+ }
+
+ return firstLine + "\n" + Truncate(rest, remaining)
+}
diff --git a/pkg/utils/tool_feedback_dedupe.go b/pkg/utils/tool_feedback_dedupe.go
new file mode 100644
index 000000000..b1adb60eb
--- /dev/null
+++ b/pkg/utils/tool_feedback_dedupe.go
@@ -0,0 +1,39 @@
+package utils
+
+import (
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func normalizeToolFeedbackComparisonText(text string) string {
+ text = strings.ReplaceAll(text, "\r\n", "\n")
+ text = strings.ReplaceAll(text, "\r", "\n")
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return ""
+ }
+ return strings.Join(strings.Fields(text), " ")
+}
+
+func ToolCallExplanationDuplicatesContent(content string, toolCalls []providers.ToolCall) bool {
+ normalizedContent := normalizeToolFeedbackComparisonText(content)
+ if normalizedContent == "" || len(toolCalls) == 0 {
+ return false
+ }
+
+ for _, tc := range toolCalls {
+ if tc.ExtraContent == nil {
+ continue
+ }
+ explanation := normalizeToolFeedbackComparisonText(tc.ExtraContent.ToolFeedbackExplanation)
+ if explanation == "" {
+ continue
+ }
+ if explanation == normalizedContent {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/pkg/utils/tool_feedback_dedupe_test.go b/pkg/utils/tool_feedback_dedupe_test.go
new file mode 100644
index 000000000..cc587080f
--- /dev/null
+++ b/pkg/utils/tool_feedback_dedupe_test.go
@@ -0,0 +1,55 @@
+package utils
+
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func TestToolCallExplanationDuplicatesContent(t *testing.T) {
+ t.Run("exact duplicate", func(t *testing.T) {
+ toolCalls := []providers.ToolCall{{
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: "Read the file before replying.",
+ },
+ }}
+
+ if !ToolCallExplanationDuplicatesContent("Read the file before replying.", toolCalls) {
+ t.Fatal("expected duplicated content to be detected")
+ }
+ })
+
+ t.Run("whitespace normalized duplicate", func(t *testing.T) {
+ toolCalls := []providers.ToolCall{{
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: "Read the file\nbefore replying.",
+ },
+ }}
+
+ if !ToolCallExplanationDuplicatesContent(" Read the file before replying. ", toolCalls) {
+ t.Fatal("expected whitespace-only differences to be ignored")
+ }
+ })
+
+ t.Run("distinct content", func(t *testing.T) {
+ toolCalls := []providers.ToolCall{{
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: "Read the file before replying.",
+ },
+ }}
+
+ if ToolCallExplanationDuplicatesContent(
+ "I will summarize the findings after reading the file.",
+ toolCalls,
+ ) {
+ t.Fatal("expected distinct content to remain visible")
+ }
+ })
+
+ t.Run("missing explanation", func(t *testing.T) {
+ toolCalls := []providers.ToolCall{{}}
+ if ToolCallExplanationDuplicatesContent("Read the file before replying.", toolCalls) {
+ t.Fatal("expected empty tool explanations to skip dedupe")
+ }
+ })
+}
diff --git a/pkg/utils/tool_feedback_test.go b/pkg/utils/tool_feedback_test.go
new file mode 100644
index 000000000..da4accce4
--- /dev/null
+++ b/pkg/utils/tool_feedback_test.go
@@ -0,0 +1,156 @@
+package utils
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestFormatToolFeedbackMessage(t *testing.T) {
+ got := FormatToolFeedbackMessage(
+ "read_file",
+ "I will read README.md first to confirm the current project structure.",
+ "{\n \"path\": \"README.md\"\n}",
+ )
+ want := "\U0001f527 `read_file`\nI will read README.md first to confirm the current project structure.\n```json\n{\n \"path\": \"README.md\"\n}\n```"
+ if got != want {
+ t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatToolFeedbackMessage_EmptyExplanationShowsArgs(t *testing.T) {
+ got := FormatToolFeedbackMessage("read_file", "", "{\n \"path\": \"README.md\"\n}")
+ want := "\U0001f527 `read_file`\n```json\n{\n \"path\": \"README.md\"\n}\n```"
+ if got != want {
+ t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) {
+ got := FormatToolFeedbackMessage("", "Continue drafting the final response.", "")
+ want := "Continue drafting the final response."
+ if got != want {
+ t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatToolFeedbackMessage_EmptyExplanationAndArgsKeepsOnlyToolLine(t *testing.T) {
+ got := FormatToolFeedbackMessage("read_file", "", "")
+ want := "\U0001f527 `read_file`"
+ if got != want {
+ t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
+ }
+}
+
+func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) {
+ got := FitToolFeedbackMessage(
+ "\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.",
+ 40,
+ )
+ want := "\U0001f527 `read_file`\nRead README.md first to..."
+ if got != want {
+ t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want)
+ }
+}
+
+func TestFitToolFeedbackMessage_TruncatesSingleLineMessage(t *testing.T) {
+ got := FitToolFeedbackMessage("\U0001f527 `read_file`", 10)
+ want := "\U0001f527 `read..."
+ if got != want {
+ t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatArgsJSON_Defaults(t *testing.T) {
+ args := map[string]any{"path": "README.md", "line": 42}
+ got := FormatArgsJSON(args, false, false)
+ var gotVal, wantVal any
+ if err := json.Unmarshal([]byte(got), &gotVal); err != nil {
+ t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err)
+ }
+ want := `{"path":"README.md","line":42}`
+ if err := json.Unmarshal([]byte(want), &wantVal); err != nil {
+ t.Fatalf("invalid test want JSON: %v", err)
+ }
+ if !jsonValEq(gotVal, wantVal) {
+ t.Fatalf("FormatArgsJSON() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatArgsJSON_PrettyPrint(t *testing.T) {
+ args := map[string]any{"path": "README.md", "line": 42}
+ got := FormatArgsJSON(args, true, false)
+ var gotVal any
+ if err := json.Unmarshal([]byte(got), &gotVal); err != nil {
+ t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err)
+ }
+ want := `{"path":"README.md","line":42}`
+ var wantVal any
+ if err := json.Unmarshal([]byte(want), &wantVal); err != nil {
+ t.Fatalf("invalid test want JSON: %v", err)
+ }
+ if !jsonValEq(gotVal, wantVal) {
+ t.Fatalf("FormatArgsJSON() prettyPrint = %q, want structure %q", got, want)
+ }
+}
+
+func TestFormatArgsJSON_DisableEscapeHTML(t *testing.T) {
+ args := map[string]any{"msg": "a < b && c > d"}
+ got := FormatArgsJSON(args, false, true)
+ var gotVal, wantVal any
+ want := `{"msg":"a < b && c > d"}`
+ if err := json.Unmarshal([]byte(got), &gotVal); err != nil {
+ t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err)
+ }
+ if err := json.Unmarshal([]byte(want), &wantVal); err != nil {
+ t.Fatalf("invalid test want JSON: %v", err)
+ }
+ if !jsonValEq(gotVal, wantVal) {
+ t.Fatalf("FormatArgsJSON() disableEscapeHTML = %q, want %q", got, want)
+ }
+}
+
+func TestFormatArgsJSON_PrettyPrintAndDisableEscapeHTML(t *testing.T) {
+ args := map[string]any{"msg": "a < b && c > d"}
+ got := FormatArgsJSON(args, true, true)
+ var gotVal, wantVal any
+ want := `{"msg":"a < b && c > d"}`
+ if err := json.Unmarshal([]byte(got), &gotVal); err != nil {
+ t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err)
+ }
+ if err := json.Unmarshal([]byte(want), &wantVal); err != nil {
+ t.Fatalf("invalid test want JSON: %v", err)
+ }
+ if !jsonValEq(gotVal, wantVal) {
+ t.Fatalf("FormatArgsJSON() combined = %q, want %q", got, want)
+ }
+}
+
+func TestFormatArgsJSON_EscapeHTMLByDefault(t *testing.T) {
+ args := map[string]any{"msg": "a < b && c > d"}
+ got := FormatArgsJSON(args, false, false)
+ var gotVal, wantVal any
+ want := `{"msg":"a \u003c b \u0026\u0026 c \u003e d"}`
+ if err := json.Unmarshal([]byte(got), &gotVal); err != nil {
+ t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err)
+ }
+ if err := json.Unmarshal([]byte(want), &wantVal); err != nil {
+ t.Fatalf("invalid test want JSON: %v", err)
+ }
+ if !jsonValEq(gotVal, wantVal) {
+ t.Fatalf("FormatArgsJSON() default escape = %q, want %q", got, want)
+ }
+}
+
+func TestFormatArgsJSON_NilArgs(t *testing.T) {
+ got := FormatArgsJSON(nil, false, false)
+ want := `{}`
+ if got != want {
+ t.Fatalf("FormatArgsJSON() nil = %q, want %q", got, want)
+ }
+}
+
+func jsonValEq(a, b any) bool {
+ aJSON, _ := json.Marshal(a)
+ bJSON, _ := json.Marshal(b)
+ return string(aJSON) == string(bJSON)
+}
diff --git a/pkg/utils/visible_tool_calls.go b/pkg/utils/visible_tool_calls.go
new file mode 100644
index 000000000..8c4d89a51
--- /dev/null
+++ b/pkg/utils/visible_tool_calls.go
@@ -0,0 +1,106 @@
+package utils
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+type VisibleToolCall struct {
+ ID string `json:"id,omitempty"`
+ Type string `json:"type,omitempty"`
+ Function *VisibleToolCallFunction `json:"function,omitempty"`
+ ExtraContent *VisibleToolCallExtraContent `json:"extra_content,omitempty"`
+}
+
+type VisibleToolCallFunction struct {
+ Name string `json:"name,omitempty"`
+ Arguments string `json:"arguments,omitempty"`
+}
+
+type VisibleToolCallExtraContent struct {
+ ToolFeedbackExplanation string `json:"tool_feedback_explanation,omitempty"`
+}
+
+func BuildVisibleToolCalls(
+ toolCalls []providers.ToolCall,
+ maxArgsLen int,
+) []VisibleToolCall {
+ if len(toolCalls) == 0 {
+ return nil
+ }
+
+ visible := make([]VisibleToolCall, 0, len(toolCalls))
+ for _, tc := range toolCalls {
+ name, _ := VisibleToolCallNameAndArguments(tc)
+ argsPreview := VisibleToolCallArgumentsPreview(tc, maxArgsLen)
+ explanation := ""
+ if tc.ExtraContent != nil {
+ explanation = strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation)
+ }
+ if name == "" && explanation == "" && argsPreview == "" {
+ continue
+ }
+
+ visibleCall := VisibleToolCall{
+ ID: strings.TrimSpace(tc.ID),
+ Type: strings.TrimSpace(tc.Type),
+ }
+ if visibleCall.Type == "" {
+ visibleCall.Type = "function"
+ }
+ if name != "" || argsPreview != "" {
+ visibleCall.Function = &VisibleToolCallFunction{
+ Name: name,
+ Arguments: argsPreview,
+ }
+ }
+ if explanation != "" {
+ visibleCall.ExtraContent = &VisibleToolCallExtraContent{
+ ToolFeedbackExplanation: explanation,
+ }
+ }
+
+ visible = append(visible, visibleCall)
+ }
+
+ if len(visible) == 0 {
+ return nil
+ }
+ return visible
+}
+
+func VisibleToolCallNameAndArguments(tc providers.ToolCall) (string, string) {
+ name := strings.TrimSpace(tc.Name)
+ argsJSON := ""
+ if tc.Function != nil {
+ if name == "" {
+ name = strings.TrimSpace(tc.Function.Name)
+ }
+ argsJSON = strings.TrimSpace(tc.Function.Arguments)
+ }
+ if argsJSON == "" && len(tc.Arguments) > 0 {
+ if encodedArgs, err := json.Marshal(tc.Arguments); err == nil {
+ argsJSON = string(encodedArgs)
+ }
+ }
+ return name, strings.TrimSpace(argsJSON)
+}
+
+func VisibleToolCallArgumentsPreview(tc providers.ToolCall, maxLen int) string {
+ _, argsJSON := VisibleToolCallNameAndArguments(tc)
+ if argsJSON == "" {
+ return ""
+ }
+
+ var pretty bytes.Buffer
+ if err := json.Indent(&pretty, []byte(argsJSON), "", " "); err == nil {
+ argsJSON = pretty.String()
+ }
+ if maxLen > 0 {
+ return Truncate(argsJSON, maxLen)
+ }
+ return argsJSON
+}
diff --git a/pkg/utils/visible_tool_calls_test.go b/pkg/utils/visible_tool_calls_test.go
new file mode 100644
index 000000000..fe9467c57
--- /dev/null
+++ b/pkg/utils/visible_tool_calls_test.go
@@ -0,0 +1,33 @@
+package utils
+
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func TestBuildVisibleToolCalls_DoesNotTruncateExplanation(t *testing.T) {
+ explanation := "Read README.md first to confirm the current project structure before editing the config example."
+ toolCalls := []providers.ToolCall{{
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}`,
+ },
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: explanation,
+ },
+ }}
+
+ visible := BuildVisibleToolCalls(toolCalls, 20)
+ if len(visible) != 1 {
+ t.Fatalf("len(visible) = %d, want 1", len(visible))
+ }
+ if visible[0].ExtraContent == nil || visible[0].ExtraContent.ToolFeedbackExplanation != explanation {
+ t.Fatalf("visible explanation = %#v, want %q", visible[0].ExtraContent, explanation)
+ }
+ if visible[0].Function == nil || visible[0].Function.Arguments == "" {
+ t.Fatalf("visible function = %#v, want truncated args preview", visible[0].Function)
+ }
+}
diff --git a/pkg/voice/elevenlabs_transcriber_test.go b/pkg/voice/elevenlabs_transcriber_test.go
deleted file mode 100644
index 78be8958a..000000000
--- a/pkg/voice/elevenlabs_transcriber_test.go
+++ /dev/null
@@ -1,83 +0,0 @@
-package voice
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "testing"
-)
-
-// Ensure ElevenLabsTranscriber satisfies the Transcriber interface at compile time.
-var _ Transcriber = (*ElevenLabsTranscriber)(nil)
-
-func TestElevenLabsTranscriberName(t *testing.T) {
- tr := NewElevenLabsTranscriber("sk_test")
- if got := tr.Name(); got != "elevenlabs" {
- t.Errorf("Name() = %q, want %q", got, "elevenlabs")
- }
-}
-
-func TestElevenLabsTranscribe(t *testing.T) {
- tmpDir := t.TempDir()
- audioPath := filepath.Join(tmpDir, "clip.ogg")
- if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil {
- t.Fatalf("failed to write fake audio file: %v", err)
- }
-
- t.Run("success", func(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/v1/speech-to-text" {
- t.Errorf("unexpected path: %s", r.URL.Path)
- }
- if r.Header.Get("Xi-Api-Key") != "sk_test" {
- t.Errorf("unexpected xi-api-key header: %s", r.Header.Get("Xi-Api-Key"))
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(TranscriptionResponse{
- Text: "hello from elevenlabs",
- Language: "en",
- })
- }))
- defer srv.Close()
-
- tr := NewElevenLabsTranscriber("sk_test")
- tr.apiBase = srv.URL
-
- resp, err := tr.Transcribe(context.Background(), audioPath)
- if err != nil {
- t.Fatalf("Transcribe() error: %v", err)
- }
- if resp.Text != "hello from elevenlabs" {
- t.Errorf("Text = %q, want %q", resp.Text, "hello from elevenlabs")
- }
- if resp.Language != "en" {
- t.Errorf("Language = %q, want %q", resp.Language, "en")
- }
- })
-
- t.Run("api error", func(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized)
- }))
- defer srv.Close()
-
- tr := NewElevenLabsTranscriber("sk_bad")
- tr.apiBase = srv.URL
-
- _, err := tr.Transcribe(context.Background(), audioPath)
- if err == nil {
- t.Fatal("expected error for non-200 response, got nil")
- }
- })
-
- t.Run("missing file", func(t *testing.T) {
- tr := NewElevenLabsTranscriber("sk_test")
- _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
- if err == nil {
- t.Fatal("expected error for missing file, got nil")
- }
- })
-}
diff --git a/pkg/voice/groq_transcriber.go b/pkg/voice/groq_transcriber.go
deleted file mode 100644
index b42e598f7..000000000
--- a/pkg/voice/groq_transcriber.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package voice
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "mime/multipart"
- "net/http"
- "os"
- "path/filepath"
- "time"
-
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/utils"
-)
-
-type GroqTranscriber struct {
- apiKey string
- apiBase string
- httpClient *http.Client
-}
-
-func NewGroqTranscriber(apiKey string) *GroqTranscriber {
- logger.DebugCF("voice", "Creating Groq transcriber", map[string]any{"has_api_key": apiKey != ""})
-
- apiBase := "https://api.groq.com/openai/v1"
- return &GroqTranscriber{
- apiKey: apiKey,
- apiBase: apiBase,
- httpClient: &http.Client{
- Timeout: 60 * time.Second,
- },
- }
-}
-
-func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
- logger.InfoCF("voice", "Starting transcription", map[string]any{"audio_file": audioFilePath})
-
- audioFile, err := os.Open(audioFilePath)
- if err != nil {
- logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err})
- return nil, fmt.Errorf("failed to open audio file: %w", err)
- }
- defer audioFile.Close()
-
- fileInfo, err := audioFile.Stat()
- if err != nil {
- logger.ErrorCF("voice", "Failed to get file info", map[string]any{"path": audioFilePath, "error": err})
- return nil, fmt.Errorf("failed to get file info: %w", err)
- }
-
- logger.DebugCF("voice", "Audio file details", map[string]any{
- "size_bytes": fileInfo.Size(),
- "file_name": filepath.Base(audioFilePath),
- })
-
- var requestBody bytes.Buffer
- writer := multipart.NewWriter(&requestBody)
-
- part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath))
- if err != nil {
- logger.ErrorCF("voice", "Failed to create form file", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to create form file: %w", err)
- }
-
- copied, err := io.Copy(part, audioFile)
- if err != nil {
- logger.ErrorCF("voice", "Failed to copy file content", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to copy file content: %w", err)
- }
-
- logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied})
-
- if err = writer.WriteField("model", "whisper-large-v3"); err != nil {
- logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to write model field: %w", err)
- }
-
- if err = writer.WriteField("response_format", "json"); err != nil {
- logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to write response_format field: %w", err)
- }
-
- if err = writer.Close(); err != nil {
- logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to close multipart writer: %w", err)
- }
-
- url := t.apiBase + "/audio/transcriptions"
- req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody)
- if err != nil {
- logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Content-Type", writer.FormDataContentType())
- req.Header.Set("Authorization", "Bearer "+t.apiKey)
-
- logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]any{
- "url": url,
- "request_size_bytes": requestBody.Len(),
- "file_size_bytes": fileInfo.Size(),
- })
-
- resp, err := t.httpClient.Do(req)
- if err != nil {
- logger.ErrorCF("voice", "Failed to send request", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to send request: %w", err)
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- logger.ErrorCF("voice", "Failed to read response", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to read response: %w", err)
- }
-
- if resp.StatusCode != http.StatusOK {
- logger.ErrorCF("voice", "API error", map[string]any{
- "status_code": resp.StatusCode,
- "response": string(body),
- })
- return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
- }
-
- logger.DebugCF("voice", "Received response from Groq API", map[string]any{
- "status_code": resp.StatusCode,
- "response_size_bytes": len(body),
- })
-
- var result TranscriptionResponse
- if err := json.Unmarshal(body, &result); err != nil {
- logger.ErrorCF("voice", "Failed to unmarshal response", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to unmarshal response: %w", err)
- }
-
- logger.InfoCF("voice", "Transcription completed successfully", map[string]any{
- "text_length": len(result.Text),
- "language": result.Language,
- "duration_seconds": result.Duration,
- "transcription_preview": utils.Truncate(result.Text, 50),
- })
-
- return &result, nil
-}
-
-func (t *GroqTranscriber) Name() string {
- return "groq"
-}
diff --git a/pkg/voice/groq_transcriber_test.go b/pkg/voice/groq_transcriber_test.go
deleted file mode 100644
index fdcaa7580..000000000
--- a/pkg/voice/groq_transcriber_test.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package voice
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "testing"
-)
-
-var _ Transcriber = (*GroqTranscriber)(nil)
-
-func TestGroqTranscriberName(t *testing.T) {
- tr := NewGroqTranscriber("sk-test")
- if got := tr.Name(); got != "groq" {
- t.Errorf("Name() = %q, want %q", got, "groq")
- }
-}
-
-func TestGroqTranscribe(t *testing.T) {
- // Write a minimal fake audio file so the transcriber can open and send it.
- tmpDir := t.TempDir()
- audioPath := filepath.Join(tmpDir, "clip.ogg")
- if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil {
- t.Fatalf("failed to write fake audio file: %v", err)
- }
-
- t.Run("success", func(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/audio/transcriptions" {
- t.Errorf("unexpected path: %s", r.URL.Path)
- }
- if r.Header.Get("Authorization") != "Bearer sk-test" {
- t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization"))
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(TranscriptionResponse{
- Text: "hello world",
- Language: "en",
- Duration: 1.5,
- })
- }))
- defer srv.Close()
-
- tr := NewGroqTranscriber("sk-test")
- tr.apiBase = srv.URL
-
- resp, err := tr.Transcribe(context.Background(), audioPath)
- if err != nil {
- t.Fatalf("Transcribe() error: %v", err)
- }
- if resp.Text != "hello world" {
- t.Errorf("Text = %q, want %q", resp.Text, "hello world")
- }
- if resp.Language != "en" {
- t.Errorf("Language = %q, want %q", resp.Language, "en")
- }
- })
-
- t.Run("api error", func(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized)
- }))
- defer srv.Close()
-
- tr := NewGroqTranscriber("sk-bad")
- tr.apiBase = srv.URL
-
- _, err := tr.Transcribe(context.Background(), audioPath)
- if err == nil {
- t.Fatal("expected error for non-200 response, got nil")
- }
- })
-
- t.Run("missing file", func(t *testing.T) {
- tr := NewGroqTranscriber("sk-test")
- _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
- if err == nil {
- t.Fatal("expected error for missing file, got nil")
- }
- })
-}
diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go
deleted file mode 100644
index f56fdeedd..000000000
--- a/pkg/voice/transcriber.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package voice
-
-import (
- "context"
- "strings"
-
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/providers"
-)
-
-type Transcriber interface {
- Name() string
- Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
-}
-
-type TranscriptionResponse struct {
- Text string `json:"text"`
- Language string `json:"language,omitempty"`
- Duration float64 `json:"duration,omitempty"`
-}
-
-func supportsAudioTranscription(model string) bool {
- protocol, _ := providers.ExtractProtocol(model)
-
- switch protocol {
- case "openai", "azure", "azure-openai",
- "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
- "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
- "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
- "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita",
- "coding-plan", "alibaba-coding", "qwen-coding":
- // These protocols all go through the OpenAI-compatible or Azure provider path in
- // providers.CreateProviderFromConfig, so they are the only ones that can supply
- // the audio media payload shape expected by NewAudioModelTranscriber.
-
- // TODO: Further restrict this by modelID, since not every model under these
- // protocols supports audio transcription.
- return true
- default:
- return false
- }
-}
-
-// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or
-// nil if no supported transcription provider is configured.
-func DetectTranscriber(cfg *config.Config) Transcriber {
- if modelName := strings.TrimSpace(cfg.Voice.ModelName); modelName != "" {
- modelCfg, err := cfg.GetModelConfig(modelName)
- if err != nil {
- return nil
- }
- if supportsAudioTranscription(modelCfg.Model) {
- return NewAudioModelTranscriber(modelCfg)
- }
- }
-
- // ElevenLabs voice config (supports Scribe STT).
- if key := strings.TrimSpace(cfg.Voice.ElevenLabsAPIKey); key != "" {
- return NewElevenLabsTranscriber(key)
- }
- // Fall back to any model-list entry that uses the groq/ protocol.
- for _, mc := range cfg.ModelList {
- if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey() != "" {
- return NewGroqTranscriber(mc.APIKey())
- }
- }
- return nil
-}
diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh
index 76cc72938..df2100aec 100755
--- a/scripts/build-macos-app.sh
+++ b/scripts/build-macos-app.sh
@@ -10,6 +10,8 @@ if [ -z "$EXECUTABLE" ]; then
exit 1
fi
+LAUNCHER_EXECUTABLE="picoclaw-launcher-${EXECUTABLE}"
+EXECUTABLE="picoclaw-${EXECUTABLE}"
echo "executable: $EXECUTABLE"
APP_NAME="PicoClaw Launcher"
@@ -33,17 +35,17 @@ mkdir -p "$APP_RESOURCES"
# Copy executable
echo "Copying executable..."
-if [ -f "./web/build/${APP_EXECUTABLE}" ]; then
- cp "./web/build/${APP_EXECUTABLE}" "${APP_MACOS}/"
+if [ -f "./build/${LAUNCHER_EXECUTABLE}" ]; then
+ cp "./build/${LAUNCHER_EXECUTABLE}" "${APP_MACOS}/${APP_EXECUTABLE}"
else
- echo "Error: ./web/build/${APP_EXECUTABLE} not found. Please build the web backend first."
- echo "Run: make build in web dir"
+ echo "Error: ./build/${LAUNCHER_EXECUTABLE} not found. Please build the web backend first."
+ echo "Run: make build-launcher"
exit 1
fi
-if [ -f "./build/picoclaw" ]; then
- cp "./build/picoclaw" "${APP_MACOS}/"
+if [ -f "./build/${EXECUTABLE}" ]; then
+ cp "./build/${EXECUTABLE}" "${APP_MACOS}/picoclaw"
else
- echo "Error: ./build/picoclaw not found. Please build the main file first."
+ echo "Error: ./build/${EXECUTABLE} not found. Please build the main file first."
echo "Run: make build"
exit 1
fi
@@ -76,10 +78,10 @@ cat > "${APP_CONTENTS}/Info.plist" << 'EOF'
NSSupportsAutomaticGraphicsSwitching
- LSRequiresCarbon
-
LSUIElement
- 1
+
+ LSMinimumSystemVersion
+ 10.11
EOF
diff --git a/scripts/copydir.go b/scripts/copydir.go
new file mode 100644
index 000000000..6e2777612
--- /dev/null
+++ b/scripts/copydir.go
@@ -0,0 +1,186 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+)
+
+func main() {
+ if len(os.Args) != 3 {
+ fmt.Fprintf(os.Stderr, "usage: go run scripts/copydir.go \n")
+ os.Exit(2)
+ }
+
+ repoRoot, err := findRepoRoot()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "locate repo root: %v\n", err)
+ os.Exit(1)
+ }
+
+ src, err := normalizePathArg(os.Args[1], repoRoot)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "resolve src path: %v\n", err)
+ os.Exit(1)
+ }
+
+ dst, err := normalizePathArg(os.Args[2], repoRoot)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "resolve dst path: %v\n", err)
+ os.Exit(1)
+ }
+
+ if err := ensurePathWithinRepo(repoRoot, src); err != nil {
+ fmt.Fprintf(os.Stderr, "invalid src path: %v\n", err)
+ os.Exit(1)
+ }
+ if err := ensurePathWithinRepo(repoRoot, dst); err != nil {
+ fmt.Fprintf(os.Stderr, "invalid dst path: %v\n", err)
+ os.Exit(1)
+ }
+ if samePath(repoRoot, dst) {
+ fmt.Fprintln(os.Stderr, "invalid dst path: destination cannot be repo root")
+ os.Exit(1)
+ }
+
+ if err := os.RemoveAll(dst); err != nil {
+ fmt.Fprintf(os.Stderr, "remove %s: %v\n", dst, err)
+ os.Exit(1)
+ }
+
+ if err := copyTree(src, dst); err != nil {
+ fmt.Fprintf(os.Stderr, "copy %s -> %s: %v\n", src, dst, err)
+ os.Exit(1)
+ }
+}
+
+func findRepoRoot() (string, error) {
+ _, file, _, ok := runtime.Caller(0)
+ if !ok {
+ return "", fmt.Errorf("unable to locate copydir.go source path")
+ }
+
+ scriptDir := filepath.Dir(file)
+ candidate := filepath.Clean(filepath.Join(scriptDir, ".."))
+ if err := validateRepoRoot(candidate); err == nil {
+ return candidate, nil
+ }
+
+ wd, err := os.Getwd()
+ if err != nil {
+ return "", err
+ }
+
+ cur, err := filepath.Abs(wd)
+ if err != nil {
+ return "", err
+ }
+
+ for {
+ if err := validateRepoRoot(cur); err == nil {
+ return filepath.Clean(cur), nil
+ }
+ parent := filepath.Dir(cur)
+ if parent == cur {
+ return "", fmt.Errorf("could not find repository root from %s", wd)
+ }
+ cur = parent
+ }
+}
+
+func validateRepoRoot(root string) error {
+ anchors := []string{
+ filepath.Join(root, "go.sum"),
+ filepath.Join(root, "LICENSE"),
+ filepath.Join(root, ".github"),
+ }
+ for _, anchor := range anchors {
+ if _, err := os.Stat(anchor); err != nil {
+ return fmt.Errorf("missing repo anchor %s: %w", anchor, err)
+ }
+ }
+ return nil
+}
+
+func normalizePathArg(arg, repoRoot string) (string, error) {
+ resolved := strings.ReplaceAll(arg, "${codespace}", repoRoot)
+ abs, err := filepath.Abs(resolved)
+ if err != nil {
+ return "", err
+ }
+ return filepath.Clean(abs), nil
+}
+
+func ensurePathWithinRepo(repoRoot, path string) error {
+ rel, err := filepath.Rel(repoRoot, path)
+ if err != nil {
+ return err
+ }
+ if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return fmt.Errorf("path %s is outside repository root %s", path, repoRoot)
+ }
+ return nil
+}
+
+func samePath(a, b string) bool {
+ return filepath.Clean(a) == filepath.Clean(b)
+}
+
+func copyTree(src, dst string) error {
+ info, err := os.Stat(src)
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() {
+ return fmt.Errorf("source is not a directory: %s", src)
+ }
+
+ return filepath.Walk(src, func(path string, entry os.FileInfo, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+
+ rel, err := filepath.Rel(src, path)
+ if err != nil {
+ return err
+ }
+
+ target := dst
+ if rel != "." {
+ target = filepath.Join(dst, rel)
+ }
+
+ if entry.IsDir() {
+ return os.MkdirAll(target, entry.Mode())
+ }
+
+ return copyFile(path, target, entry.Mode())
+ })
+}
+
+func copyFile(src, dst string, mode os.FileMode) error {
+ if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+ return err
+ }
+
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+
+ out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ if _, err := io.Copy(out, in); err != nil {
+ return err
+ }
+
+ return out.Close()
+}
diff --git a/scripts/lint-docs.sh b/scripts/lint-docs.sh
new file mode 100755
index 000000000..7351298b6
--- /dev/null
+++ b/scripts/lint-docs.sh
@@ -0,0 +1,219 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+cd "$(git rev-parse --show-toplevel)"
+
+failures=0
+
+error() {
+ local path="$1"
+ local reason="$2"
+ local suggestion="${3:-}"
+
+ echo "docs lint: $path" >&2
+ echo " reason: $reason" >&2
+ if [[ -n "$suggestion" ]]; then
+ echo " fix: $suggestion" >&2
+ fi
+ failures=1
+}
+
+lowercase() {
+ printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
+}
+
+suggest_noncanonical_translation_name() {
+ local path="$1"
+ local dir
+ local base
+ local stem
+ local locale
+
+ dir="$(dirname "$path")"
+ base="$(basename "$path")"
+
+ if [[ "$base" =~ ^(.+)_([A-Za-z]{2}(-[A-Za-z]{2})?)\.md$ ]]; then
+ stem="${BASH_REMATCH[1]}"
+ locale="$(lowercase "${BASH_REMATCH[2]}")"
+ printf '%s/%s.%s.md' "$dir" "$stem" "$locale"
+ return
+ fi
+
+ if [[ "$base" =~ ^(.+)\.([A-Za-z]{2}(-[A-Za-z]{2})?)\.md$ ]]; then
+ stem="${BASH_REMATCH[1]}"
+ locale="$(lowercase "${BASH_REMATCH[2]}")"
+ printf '%s/%s.%s.md' "$dir" "$stem" "$locale"
+ return
+ fi
+
+ printf 'rename it to use a lowercase ..md suffix beside the English source'
+}
+
+suggest_docs_language_bucket_target() {
+ local path="$1"
+ local locale
+ local file
+ local name
+ local -a matches
+
+ if [[ "$path" =~ ^docs/([A-Za-z]{2}(-[A-Za-z]{2})?)/.+\.md$ ]]; then
+ locale="$(lowercase "${BASH_REMATCH[1]}")"
+ file="$(basename "$path")"
+ name="${file%.md}"
+ mapfile -t matches < <(find docs/project docs/guides docs/reference docs/operations docs/security docs/architecture docs/channels docs/design docs/migration -type f -name "${name}.md" 2>/dev/null | sort)
+ if [[ "${#matches[@]}" -eq 1 ]]; then
+ printf '%s' "${matches[0]%.md}.${locale}.md"
+ return
+ fi
+ fi
+
+ printf 'move it to a typed docs directory and rename it to ..md beside the English source'
+}
+
+suggest_nested_locale_bucket_target() {
+ local path="$1"
+ local prefix
+ local locale
+ local rest
+
+ if [[ "$path" =~ ^(docs/(project|guides|reference|operations|security|architecture|design|migration))/([A-Za-z]{2}(-[A-Za-z]{2})?)/(.*)\.md$ ]]; then
+ prefix="${BASH_REMATCH[1]}"
+ locale="$(lowercase "${BASH_REMATCH[3]}")"
+ rest="${BASH_REMATCH[5]}"
+ printf '%s/%s.%s.md' "$prefix" "$rest" "$locale"
+ return
+ fi
+
+ if [[ "$path" =~ ^(docs/channels/[^/]+)/([A-Za-z]{2}(-[A-Za-z]{2})?)/(.*)\.md$ ]]; then
+ prefix="${BASH_REMATCH[1]}"
+ locale="$(lowercase "${BASH_REMATCH[2]}")"
+ rest="${BASH_REMATCH[4]}"
+ printf '%s/%s.%s.md' "$prefix" "$rest" "$locale"
+ return
+ fi
+
+ printf 'move the file beside its English source and rename it to ..md'
+}
+
+is_noncanonical_translation_name() {
+ local path="$1"
+ local base
+
+ base="$(basename "$path")"
+
+ [[ "$base" =~ ^.+_[A-Za-z]{2}(-[A-Za-z]{2})?\.md$ ]] && return 0
+ [[ "$base" =~ ^.+\.[A-Z]{2}(-[A-Z]{2})?\.md$ ]] && return 0
+ [[ "$base" =~ ^.+\.[a-z]{2}-[A-Z]{2}\.md$ ]] && return 0
+ [[ "$base" =~ ^.+\.[A-Z]{2}-[a-z]{2}\.md$ ]] && return 0
+
+ return 1
+}
+
+is_noncanonical_locale_bucket() {
+ local path="$1"
+
+ [[ "$path" =~ ^docs/(project|guides|reference|operations|security|architecture|design|migration)/[A-Za-z]{2}(-[A-Za-z]{2})?/ ]] && return 0
+ [[ "$path" =~ ^docs/channels/[^/]+/[A-Za-z]{2}(-[A-Za-z]{2})?/ ]] && return 0
+ return 1
+}
+
+is_root_docs_language_bucket() {
+ local path="$1"
+ [[ "$path" =~ ^docs/[A-Za-z]{2}(-[A-Za-z]{2})?/ ]]
+}
+
+is_translation_file() {
+ local path="$1"
+ [[ "$path" =~ ^(.+)\.([a-z]{2})(-[a-z]{2})?\.md$ ]]
+}
+
+translation_base() {
+ local path="$1"
+ local locale="$2"
+
+ if [[ "$path" == docs/project/* ]]; then
+ local rel="${path#docs/project/}"
+ echo "${rel%.$locale.md}.md"
+ return
+ fi
+
+ echo "${path%.$locale.md}.md"
+}
+
+while IFS= read -r path; do
+ [[ -f "$path" ]] || continue
+
+ case "$path" in
+ README.*.md)
+ error \
+ "$path" \
+ "translated project entry docs must live under docs/project/" \
+ "move it to docs/project/$(basename "$path")"
+ ;;
+ CONTRIBUTING.*.md)
+ error \
+ "$path" \
+ "translated project entry docs must live under docs/project/" \
+ "move it to docs/project/$(basename "$path")"
+ ;;
+ esac
+
+ if [[ "$path" =~ (^|/)README_[A-Za-z0-9-]+\.md$ ]]; then
+ error \
+ "$path" \
+ "legacy README translation names are not allowed" \
+ "rename it to use README..md, for example $(suggest_noncanonical_translation_name "$path")"
+ fi
+
+ if is_noncanonical_translation_name "$path"; then
+ error \
+ "$path" \
+ "translation files must use lowercase ..md suffixes and no underscore variants" \
+ "rename it to $(suggest_noncanonical_translation_name "$path")"
+ fi
+
+ if is_root_docs_language_bucket "$path"; then
+ error \
+ "$path" \
+ "language bucket directories under docs/ are not allowed" \
+ "move it to $(suggest_docs_language_bucket_target "$path")"
+ fi
+
+ if is_noncanonical_locale_bucket "$path"; then
+ error \
+ "$path" \
+ "translations must live beside the English source, not under locale-named subdirectories" \
+ "move it to $(suggest_nested_locale_bucket_target "$path")"
+ fi
+
+ if [[ "$path" =~ ^docs/[^/]+\.md$ && "$path" != "docs/README.md" ]]; then
+ error \
+ "$path" \
+ "top-level docs Markdown files must move into a typed docs/ subdirectory" \
+ "move it into one of docs/project/, docs/guides/, docs/reference/, docs/operations/, docs/security/, docs/architecture/, docs/channels/, docs/design/, or docs/migration/"
+ fi
+
+ if is_translation_file "$path"; then
+ locale="${BASH_REMATCH[2]}${BASH_REMATCH[3]}"
+
+ if [[ "$path" == docs/design/* ]]; then
+ continue
+ fi
+
+ base="$(translation_base "$path" "$locale")"
+ if [[ ! -f "$base" ]]; then
+ error \
+ "$path" \
+ "missing English source document '$base'" \
+ "add the English source document at '$base' or move this translation beside the correct English source"
+ fi
+ fi
+done < <(git ls-files --cached --others --exclude-standard -- '*.md')
+
+if [[ "$failures" -ne 0 ]]; then
+ echo "docs lint: failed" >&2
+ exit 1
+fi
+
+echo "docs lint: OK"
diff --git a/web/Makefile b/web/Makefile
index 06717f2b9..254c439e9 100644
--- a/web/Makefile
+++ b/web/Makefile
@@ -1,25 +1,74 @@
-.PHONY: dev dev-frontend dev-backend build test lint clean
+.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean \
+ build-android-arm64 build-android-bundle
# Go variables
-GO?=CGO_ENABLED=0 go
+GO?=go
WEB_GO?=$(GO)
-GOFLAGS?=-v -tags stdjson
+CGO_ENABLED?=0
+GO_BUILD_TAGS?=goolm,stdjson
+GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
+GOCACHE?=$(abspath ../.cache/go-build)
+GOMODCACHE?=$(abspath ../.cache/go-mod)
+GOTOOLCHAIN?=local
+export CGO_ENABLED
+export GOCACHE
+export GOMODCACHE
+export GOTOOLCHAIN
# Build variables
BUILD_DIR=build
+EXT=
+OUTPUT?=$(BUILD_DIR)/picoclaw-launcher$(EXT)
+OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64$(EXT)
+FRONTEND_DIR=frontend
+FRONTEND_INSTALL_STAMP=$(FRONTEND_DIR)/node_modules/.picoclaw-install-stamp
+BACKEND_DIR=backend
+BACKEND_DIST=$(BACKEND_DIR)/dist
+PICOCLAW_BINARY_NAME=picoclaw
+PICOCLAW_BINARY?=$(abspath ../build/$(PICOCLAW_BINARY_NAME))
+LAUNCHER_GUI_LDFLAG=
+
+ifeq ($(OS),Windows_NT)
+ POWERSHELL=powershell -NoProfile -Command
+ WINDOWS_GOARCH_RAW:=$(strip $(shell go env GOARCH 2>NUL))
+endif
# Version
-VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
-GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
-BUILD_TIME=$(shell date +%FT%T%z)
-GO_VERSION=$(shell $(WEB_GO) version | awk '{print $$3}')
+ifeq ($(OS),Windows_NT)
+ VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>NUL))
+ GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>NUL))
+ BUILD_TIME_RAW:=$(strip $(shell powershell -NoProfile -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK'"))
+ GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>NUL))
+else
+ VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>/dev/null))
+ GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>/dev/null))
+ BUILD_TIME_RAW:=$(strip $(shell date +%FT%T%z))
+ GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>/dev/null))
+endif
+VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev)
+GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev)
+BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev)
+GO_VERSION=$(if $(GO_VERSION_RAW),$(GO_VERSION_RAW),unknown)
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
# OS detection
-UNAME_S:=$(shell uname -s)
-UNAME_M:=$(shell uname -m)
+ifeq ($(OS),Windows_NT)
+ UNAME_S=Windows
+ ifeq ($(WINDOWS_GOARCH_RAW),amd64)
+ UNAME_M=x86_64
+ else ifeq ($(WINDOWS_GOARCH_RAW),arm64)
+ UNAME_M=arm64
+ else ifeq ($(WINDOWS_GOARCH_RAW),386)
+ UNAME_M=x86
+ else
+ UNAME_M=$(if $(WINDOWS_GOARCH_RAW),$(WINDOWS_GOARCH_RAW),x86_64)
+ endif
+else
+ UNAME_S:=$(shell uname -s)
+ UNAME_M:=$(shell uname -m)
+endif
# Platform-specific settings
ifeq ($(UNAME_S),Linux)
@@ -51,46 +100,110 @@ else ifeq ($(UNAME_S),Darwin)
endif
else ifeq ($(UNAME_S),Windows)
PLATFORM=windows
- ARCH=$(UNAME_M)
- LDFLAGS=-H=windowsgui $(LDFLAGS)
+ ifeq ($(UNAME_M),x86_64)
+ ARCH=amd64
+ else ifeq ($(UNAME_M),arm64)
+ ARCH=arm64
+ else
+ ARCH=$(UNAME_M)
+ endif
+ EXT=.exe
+ PICOCLAW_BINARY_NAME=picoclaw.exe
+ LAUNCHER_GUI_LDFLAG=-H=windowsgui
else
PLATFORM=$(UNAME_S)
ARCH=$(UNAME_M)
endif
+LAUNCHER_LDFLAGS=$(strip $(LAUNCHER_GUI_LDFLAG) $(LDFLAGS))
+
# Run both frontend and backend dev servers
-dev:
- @if [ ! -f $(BUILD_DIR)/picoclaw-launcher ] || [ ! -d backend/dist ]; then \
- echo "Build artifacts not found, building..."; \
- $(MAKE) build; \
+dev: build-dev-picoclaw
+ @if [ ! -f "$(BACKEND_DIST)/index.html" ]; then \
+ echo "Embedded frontend not found, building..."; \
+ $(MAKE) build-frontend; \
fi
@echo "Starting backend and frontend dev servers..."
- @$(MAKE) dev-backend & $(MAKE) dev-frontend
+ @$(MAKE) dev-backend BACKEND_ARGS='-no-browser' & $(MAKE) dev-frontend
# Start frontend dev server (Vite, with proxy to backend)
dev-frontend:
- cd frontend && pnpm dev
+ cd $(FRONTEND_DIR) && pnpm dev
# Start backend dev server
dev-backend:
- cd backend && ${WEB_GO} run -ldflags "$(LDFLAGS)" .
+ cd $(BACKEND_DIR) && PICOCLAW_BINARY="$(PICOCLAW_BINARY)" ${WEB_GO} run -ldflags "$(LAUNCHER_LDFLAGS)" . $(BACKEND_ARGS)
# Build frontend and embed into Go binary
-build:
- cd frontend && pnpm build:backend
- ${WEB_GO} build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/picoclaw-launcher ./backend/
+build: build-frontend
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path (Split-Path -Parent '$(OUTPUT)') | Out-Null"
+else
+ @mkdir -p "$$(dirname "$(OUTPUT)")"
+endif
+ ${WEB_GO} build $(GOFLAGS) -ldflags "$(LAUNCHER_LDFLAGS)" -o "$(OUTPUT)" ./$(BACKEND_DIR)/
+
+# Build launcher for Android ARM64 (frontend must already be built)
+build-android-arm64: build-frontend
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
+else
+ @mkdir -p $(BUILD_DIR)
+endif
+ GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(OUTPUT_ANDROID_ARM64)" ./$(BACKEND_DIR)/
+
+# Build launcher for all Android architectures
+build-android-bundle: build-frontend
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
+else
+ @mkdir -p $(BUILD_DIR)
+endif
+ GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/picoclaw-launcher-android-arm64" ./$(BACKEND_DIR)/
+ @echo "All Android launcher builds complete"
+
+build-frontend:
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "if ((-not (Test-Path -LiteralPath '$(FRONTEND_DIR)/node_modules')) -or (-not (Test-Path -LiteralPath '$(FRONTEND_DIR)/node_modules/.bin/tsc')) -or (-not (Test-Path -LiteralPath '$(FRONTEND_INSTALL_STAMP)')) -or ((Get-Content -LiteralPath '$(FRONTEND_INSTALL_STAMP)' -Raw).Trim() -ne (((Get-FileHash -LiteralPath '$(FRONTEND_DIR)/package.json' -Algorithm SHA256).Hash + ':' + (Get-FileHash -LiteralPath '$(FRONTEND_DIR)/pnpm-lock.yaml' -Algorithm SHA256).Hash)))) { Write-Host 'Installing frontend dependencies...'; Push-Location '$(FRONTEND_DIR)'; try { pnpm install --frozen-lockfile } finally { Pop-Location }; Set-Content -LiteralPath '$(FRONTEND_INSTALL_STAMP)' -Value (((Get-FileHash -LiteralPath '$(FRONTEND_DIR)/package.json' -Algorithm SHA256).Hash + ':' + (Get-FileHash -LiteralPath '$(FRONTEND_DIR)/pnpm-lock.yaml' -Algorithm SHA256).Hash)) -NoNewline }"
+else
+ @expected_stamp="$$(cat $(FRONTEND_DIR)/package.json $(FRONTEND_DIR)/pnpm-lock.yaml | cksum | awk '{print $$1 ":" $$2}')"; \
+ if [ ! -d $(FRONTEND_DIR)/node_modules ] || \
+ [ ! -x $(FRONTEND_DIR)/node_modules/.bin/tsc ] || \
+ [ ! -f $(FRONTEND_INSTALL_STAMP) ] || \
+ [ "$$(cat $(FRONTEND_INSTALL_STAMP) 2>/dev/null)" != "$$expected_stamp" ]; then \
+ echo "Installing frontend dependencies..."; \
+ (cd $(FRONTEND_DIR) && CI=true pnpm install --frozen-lockfile) && \
+ printf '%s\n' "$$expected_stamp" > $(FRONTEND_INSTALL_STAMP); \
+ fi
+endif
+ @echo "Building frontend..."
+ @cd $(FRONTEND_DIR) && pnpm build:backend
+
+build-dev-picoclaw:
+ @echo "Building picoclaw for launcher development..."
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path (Split-Path -Parent '$(PICOCLAW_BINARY)') | Out-Null"
+else
+ @mkdir -p "$$(dirname "$(PICOCLAW_BINARY)")"
+endif
+ @$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw
# Run all tests
test:
- cd backend && ${WEB_GO} test ./...
- cd frontend && pnpm lint
+ cd $(BACKEND_DIR) && ${WEB_GO} test ./...
+ cd $(FRONTEND_DIR) && pnpm lint
# Lint and format
lint:
- cd backend && ${WEB_GO} vet ./...
- cd frontend && pnpm check
+ cd $(BACKEND_DIR) && ${WEB_GO} vet ./...
+ cd $(FRONTEND_DIR) && pnpm check
# Clean build artifacts
clean:
- rm -rf frontend/dist backend/dist $(BUILD_DIR)
- mkdir -p backend/dist && touch backend/dist/.gitkeep
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "$$paths=@('$(FRONTEND_DIR)/dist','$(BACKEND_DIST)','$(BUILD_DIR)'); foreach($$p in $$paths){ if (Test-Path -LiteralPath $$p) { Remove-Item -LiteralPath $$p -Recurse -Force } }"
+ @node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs
+else
+ rm -rf $(FRONTEND_DIR)/dist $(BACKEND_DIST) $(BUILD_DIR)
+ node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs
+endif
diff --git a/web/README.md b/web/README.md
index 6ec247bae..2a57524e0 100644
--- a/web/README.md
+++ b/web/README.md
@@ -1,51 +1,367 @@
-# Picoclaw Web
+# PicoClaw Web
-This directory contains the standalone web service for `picoclaw`.
-It provides a complete unified web interface, acting as a dashboard, configuration center, and interactive console (channel client) for the core `picoclaw` engine.
+`web/` contains the standalone WebUI launcher for PicoClaw.
+It is not just a frontend: it is a small launcher service that bundles a React dashboard, exposes a backend API, manages launcher authentication, and starts or attaches to the `picoclaw gateway` process.
+
+
+
+## What This Directory Provides
+
+- A browser-based chat UI backed by the Pico channel WebSocket proxy.
+- A dashboard for models, credentials, channels, agent tools, skills, logs, and runtime settings.
+- A launcher process that can auto-open the browser, show a system tray menu, and persist launcher-specific settings.
+- A controlled way to start, stop, restart, and inspect the `picoclaw gateway` subprocess.
+- A single-binary deployment target where the frontend is embedded into the Go backend.
## Architecture
-The service is structured as a monorepo containing both the backend and frontend code to ensure high cohesion and simplify deployment.
+This directory is a small monorepo:
-* **`backend/`**: The Go-based web server. It provides RESTful APIs, manages WebSocket connections for chat, and handles the lifecycle of the `picoclaw` process. It eventually embeds the compiled frontend assets into a single executable.
-* **`frontend/`**: The Vite + React + TanStack Router single-page application (SPA). It provides the interactive user interface.
+- `backend/`
+ - Go HTTP server and launcher runtime.
+ - Serves REST APIs, authentication endpoints, channel helper flows, and the Pico WebSocket reverse proxy.
+ - Embeds compiled frontend assets from `backend/dist`.
+- `frontend/`
+ - Vite + React 19 + TanStack Router SPA.
+ - Provides the launcher dashboard and chat UI.
-## Getting Started
+At runtime the launcher and the main PicoClaw engine are separate processes:
+
+1. The launcher starts the web backend on port `18800` by default.
+2. The launcher serves the dashboard and handles dashboard authentication.
+3. When allowed, it starts or attaches to `picoclaw gateway -E`.
+4. The frontend talks only to the launcher backend.
+5. The launcher proxies chat traffic to the gateway through `/pico/ws`.
+
+## Dashboard Capabilities
+
+The current frontend exposes these major pages and flows:
+
+- `/`
+ - Chat UI with session history, default model selection, and Pico channel messaging.
+- `/models`
+ - Add, edit, delete, and set the default model.
+ - Supports API-key models, OAuth-backed models, and local/CLI-backed models.
+- `/credentials`
+ - Manage provider credentials.
+ - Current built-in flows: OpenAI, Anthropic, and Google Antigravity.
+- `/channels/*`
+ - Configure supported channels from a shared catalog.
+ - Current catalog: `weixin`, `telegram`, `discord`, `slack`, `feishu`, `dingtalk`, `line`, `qq`, `onebot`, `wecom`, `whatsapp`, `whatsapp_native`, `pico`, `maixcam`, `matrix`, `irc`.
+ - Includes QR-based binding helpers for WeChat and WeCom.
+- `/agent/skills`
+ - Browse built-in, global, and workspace skills.
+ - Import Markdown skills into the workspace and delete workspace-owned skills.
+- `/agent/tools`
+ - View tool availability and enable or disable tool switches through config-backed APIs.
+- `/config`
+ - Edit agent defaults, exec controls, cron controls, heartbeat, device monitoring, launcher networking, and launch-at-login settings.
+- `/logs`
+ - View the in-memory gateway log buffer and clear it.
+
+The UI currently supports English and Simplified Chinese, plus light and dark themes.
+
+## Runtime Behavior
+
+### Config Resolution
+
+The launcher uses the same PicoClaw config file as the main binary.
+
+- Default app config path: `~/.picoclaw/config.json`
+- Override with environment variable: `PICOCLAW_CONFIG`
+- Override with a positional CLI argument: `picoclaw-launcher /path/to/config.json`
+
+Launcher-only settings are stored beside that app config:
+
+- File name: `launcher-config.json`
+- Default location: `~/.picoclaw/launcher-config.json`
+
+That file currently stores:
+
+- `port`
+- `public`
+- `allowed_cidrs`
+
+If `-port` or `-public` are passed explicitly, the CLI flag wins for that run.
+If they are omitted, stored launcher settings are used.
+
+### First-Run Onboarding
+
+If the target config file does not exist, the launcher tries to bootstrap it automatically by running:
+
+```bash
+picoclaw onboard
+```
+
+The launcher looks for the main PicoClaw binary in this order:
+
+1. `PICOCLAW_BINARY`
+2. A `picoclaw` binary in the same directory as the launcher
+3. `picoclaw` from `PATH`
+
+If onboarding or gateway startup cannot find the main binary, set `PICOCLAW_BINARY` explicitly.
+
+### Gateway Management
+
+The launcher manages `picoclaw gateway -E`.
+
+On startup it tries to auto-start or attach to the gateway, but only when startup preconditions pass. In the current code, the main checks are:
+
+- a default model is configured
+- the default model entry is valid
+- the default model has usable credentials
+- local/runtime-probed models are reachable
+
+When a gateway process is started by the launcher, the launcher:
+
+- captures stdout and stderr into an in-memory ring buffer
+- tracks transient states such as `starting`, `restarting`, and `stopping`
+- marks restart-required when the default model or enabled tool set changed since boot
+- ensures the Pico channel is configured before startup
+
+### Launcher Authentication
+
+The dashboard is protected by password login.
+
+- First run uses `/launcher-setup` to create the dashboard password.
+- Manual login uses `/launcher-login`.
+- Successful login sets an HttpOnly session cookie.
+- Existing sessions are invalidated when the launcher process restarts; otherwise the browser cookie expires after 31 days.
+- When the launcher auto-opens a local browser after startup, it uses a one-shot loopback-only bootstrap endpoint to set the session cookie automatically.
+- On supported platforms, the password is stored as a bcrypt hash in `launcher-auth.db`.
+- On platforms where the SQLite password store is unavailable, the launcher stores the bcrypt hash in `launcher-config.json`.
+- Legacy `launcher_token` values are migrated once into password login and are removed from saved launcher config.
+- `PICOCLAW_LAUNCHER_TOKEN` is deprecated and ignored; after upgrading from env-token auth, open `/launcher-setup` to create a password.
+- URL token login and `Authorization: Bearer` dashboard auth are not supported.
+
+### Network Exposure
+
+By default the launcher listens on:
+
+```text
+127.0.0.1:18800
+```
+
+With `-public` or `public: true`, it listens on all interfaces:
+
+```text
+0.0.0.0:18800
+```
+
+When public access is enabled:
+
+- the launcher still protects the dashboard with password login
+- optional `allowed_cidrs` can restrict which client IP ranges may connect
+- the gateway host is overridden so remote clients can still use the launcher-managed proxy paths
+
+## Build And Run
### Prerequisites
-* Go 1.25+
-* Node.js 20+ with pnpm
+- Go `1.25+`
+- Node.js 20.19+ or 22.13+
+- `pnpm`
-### Development
+On macOS, the `web` Makefile enables `CGO_ENABLED=1` so tray-enabled launcher builds work as expected.
+On Darwin or FreeBSD without cgo, the launcher falls back to headless mode without a tray.
-Run both the frontend dev server and the Go backend simultaneously:
+If you want to prepare the frontend workspace manually, you can still install dependencies yourself:
+
+```bash
+cd frontend
+pnpm install
+```
+
+### Recommended Development Workflow
+
+From the `web/` directory:
```bash
make dev
```
-Or run them separately:
+This does three things:
+
+1. Builds `../build/picoclaw` for launcher development.
+2. Starts the Go backend with `PICOCLAW_BINARY` pointing at that binary.
+3. Starts the Vite frontend dev server.
+
+Use this when you want the full launcher flow during development.
+
+### Run Frontend And Backend Separately
```bash
-make dev-frontend # Vite dev server
-make dev-backend # Go backend
+make dev-frontend
+make dev-backend
```
-### Build
+Notes:
-Build the frontend and embed it into a single Go binary:
+- `dev-frontend` runs the Vite server.
+- `dev-backend` runs the Go backend only.
+- The Vite dev server proxies `/api` to `http://localhost:18800`.
+- Chat WebSocket URLs are generated by the backend, so the frontend does not hardcode gateway addresses.
+- Running `dev-backend` alone is mainly useful for backend work or when `backend/dist` already contains a built frontend.
+
+### Build The Standalone Launcher Binary
+
+From `web/`:
```bash
make build
```
-The output binary is `backend/picoclaw-web`.
+This:
-### Other Commands
+1. Installs frontend dependencies when needed.
+2. Builds the frontend into `backend/dist`.
+3. Embeds those assets into the Go backend.
+4. Produces `build/picoclaw-launcher`.
+
+Override the output path if needed:
```bash
-make test # Run backend tests and frontend lint
-make lint # Run go vet and prettier/eslint
-make clean # Remove all build artifacts
+make build OUTPUT=/tmp/picoclaw-launcher
```
+
+From the repository root you can also use:
+
+```bash
+make build-launcher
+```
+
+That writes the platform-specific launcher to:
+
+```text
+build/picoclaw-launcher--
+```
+
+and refreshes the `build/picoclaw-launcher` symlink.
+
+### Frontend-Only Builds
+
+For frontend work there are two useful package scripts:
+
+```bash
+cd frontend
+pnpm build
+pnpm build:backend
+```
+
+- `pnpm build` writes a normal Vite build to `frontend/dist`
+- `pnpm build:backend` writes the embeddable build to `../backend/dist`
+
+### Run The Built Launcher
+
+Examples:
+
+```bash
+./build/picoclaw-launcher
+./build/picoclaw-launcher -console
+./build/picoclaw-launcher -public
+./build/picoclaw-launcher -port 19999 /path/to/config.json
+```
+
+Current launcher flags:
+
+- `-port`
+- `-public`
+- `-no-browser`
+- `-lang`
+- `-console`
+
+## Make Targets
+
+From `web/`:
+
+```bash
+make dev
+make dev-frontend
+make dev-backend
+make build
+make build-frontend
+make test
+make lint
+make clean
+```
+
+What they do today:
+
+- `make build-frontend`
+ - Runs `pnpm install --frozen-lockfile` when dependencies are missing or stale.
+ - Builds the embeddable frontend into `backend/dist`.
+- `make test`
+ - Runs backend Go tests.
+ - Runs frontend `pnpm lint`.
+- `make lint`
+ - Runs backend `go vet`.
+ - Runs frontend `pnpm check`.
+ - `pnpm check` currently formats files with Prettier and fixes lint issues with ESLint, so this target can modify your working tree.
+- `make clean`
+ - Removes `frontend/dist`, `backend/dist`, and `build/`, then recreates `backend/dist/.gitkeep`.
+
+## Directory Layout
+
+```text
+web/
+├── backend/
+│ ├── api/ # REST API handlers and launcher runtime endpoints
+│ ├── launcherconfig/ # launcher-config.json load/save/validation
+│ ├── middleware/ # auth, content type, logging, CIDR allowlist
+│ ├── model/ # Go data structures and logic wrappers
+│ ├── utils/ # runtime helpers, onboarding, browser launch
+│ ├── winres/ # Windows application resources
+│ └── dist/ # embedded frontend build output
+├── frontend/
+│ ├── src/api/ # browser API clients
+│ ├── src/components/ # UI pages and shared components
+│ ├── src/features/ # feature-specific state, controllers, and protocol helpers
+│ ├── src/hooks/ # shared React hooks
+│ ├── src/i18n/ # internationalization language packs
+│ ├── src/lib/ # generic library utilities
+│ ├── src/routes/ # TanStack file routes
+│ ├── src/store/ # global state management
+│ └── vite.config.ts # dev server and build config
+├── Makefile
+└── README.md
+```
+
+## Troubleshooting
+
+### You have to sign in again after the launcher restarts
+
+Existing dashboard sessions do not survive launcher restarts.
+That is expected: each launcher process generates a new session value, so old cookies become invalid.
+Sign in again with the dashboard password on `/launcher-login`.
+
+### "Start Gateway" stays disabled
+
+The launcher only allows gateway startup when the configured default model is usable.
+Check these in the dashboard:
+
+- a default model is selected
+- the model has credentials or OAuth state
+- local models such as Ollama or vLLM are reachable
+
+### The launcher cannot find `picoclaw`
+
+Set the main binary explicitly:
+
+```bash
+export PICOCLAW_BINARY=/absolute/path/to/picoclaw
+```
+
+This affects onboarding and gateway subprocess startup.
+
+### The backend starts but the UI is blank in development
+
+Use `make dev` for the normal workflow.
+If you run only `make dev-backend`, either run `make dev-frontend` alongside it or build the embedded frontend first with `make build-frontend`.
+
+## Related Docs
+
+- Main project overview: [`../README.md`](../README.md)
+- Configuration guide: [`../docs/guides/configuration.md`](../docs/guides/configuration.md)
+- Providers: [`../docs/guides/providers.md`](../docs/guides/providers.md)
+- Troubleshooting: [`../docs/operations/troubleshooting.md`](../docs/operations/troubleshooting.md)
+- Official docs site: [docs.picoclaw.io](https://docs.picoclaw.io)
diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go
index b9b4d5f66..da07b76c0 100644
--- a/web/backend/api/auth.go
+++ b/web/backend/api/auth.go
@@ -1,8 +1,10 @@
package api
import (
+ "context"
"crypto/subtle"
"encoding/json"
+ "fmt"
"io"
"net/http"
"strings"
@@ -10,58 +12,83 @@ import (
"github.com/sipeed/picoclaw/web/backend/middleware"
)
-// LauncherAuthRouteOpts configures dashboard token login handlers.
-type LauncherAuthRouteOpts struct {
- DashboardToken string
- SessionCookie string
- SecureCookie func(*http.Request) bool
- // TokenHelp is returned on unauthenticated /api/auth/status responses (no secrets).
- TokenHelp LauncherAuthTokenHelp
+// PasswordStore is the interface for dashboard password persistence.
+// Implemented by dashboardauth.Store and launcherconfig.PasswordStore.
+type PasswordStore interface {
+ IsInitialized(ctx context.Context) (bool, error)
+ SetPassword(ctx context.Context, plain string) error
+ VerifyPassword(ctx context.Context, plain string) (bool, error)
}
-// LauncherAuthTokenHelp tells the login UI where users can find the dashboard token.
-type LauncherAuthTokenHelp struct {
- EnvVarName string `json:"env_var_name"`
- LogFileAbs string `json:"log_file,omitempty"`
- TrayCopyMenu bool `json:"tray_copy_menu"`
- ConsoleStdout bool `json:"console_stdout"`
+// LauncherAuthRouteOpts configures dashboard auth handlers.
+type LauncherAuthRouteOpts struct {
+ SessionCookie string
+ SecureCookie func(*http.Request) bool
+ // PasswordStore enables password login. It must be non-nil for auth to work.
+ PasswordStore PasswordStore
+ // StoreError holds the error returned when opening the password store. When
+ // non-nil and PasswordStore is nil, auth endpoints fail closed with a
+ // recovery message.
+ StoreError error
}
type launcherAuthLoginBody struct {
- Token string `json:"token"`
+ Password string `json:"password"`
+}
+
+type launcherAuthSetupBody struct {
+ Password string `json:"password"`
+ Confirm string `json:"confirm"`
}
type launcherAuthStatusResponse struct {
- Authenticated bool `json:"authenticated"`
- TokenHelp *LauncherAuthTokenHelp `json:"token_help,omitempty"`
+ Authenticated bool `json:"authenticated"`
+ Initialized bool `json:"initialized"`
}
-// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status.
+// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status|setup.
func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) {
secure := opts.SecureCookie
if secure == nil {
secure = middleware.DefaultLauncherDashboardSecureCookie
}
h := &launcherAuthHandlers{
- token: opts.DashboardToken,
sessionCookie: opts.SessionCookie,
secureCookie: secure,
- tokenHelp: opts.TokenHelp,
+ store: opts.PasswordStore,
+ storeErr: opts.StoreError,
loginLimit: newLoginRateLimiter(),
}
mux.HandleFunc("POST /api/auth/login", h.handleLogin)
mux.HandleFunc("POST /api/auth/logout", h.handleLogout)
mux.HandleFunc("GET /api/auth/status", h.handleStatus)
+ mux.HandleFunc("POST /api/auth/setup", h.handleSetup)
}
type launcherAuthHandlers struct {
- token string
sessionCookie string
secureCookie func(*http.Request) bool
- tokenHelp LauncherAuthTokenHelp
+ store PasswordStore
+ storeErr error // set when the store failed to open; drives recovery messages
loginLimit *loginRateLimiter
}
+// isStoreInitialized safely queries the store.
+// Returns (false, err) on store errors — callers must treat this as a 5xx, not as
+// "uninitialized", to keep auth fail-closed.
+func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, error) {
+ if h.store == nil {
+ if h.storeErr != nil {
+ return false, fmt.Errorf(
+ "password store unavailable (%w); "+
+ "to recover, stop the application, reset dashboard password storage, and restart",
+ h.storeErr)
+ }
+ return false, fmt.Errorf("password store not configured")
+ }
+ return h.store.IsInitialized(ctx)
+}
+
func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var body launcherAuthLoginBody
@@ -76,10 +103,29 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques
_, _ = w.Write([]byte(`{"error":"too many login attempts"}`))
return
}
- in := strings.TrimSpace(body.Token)
- if len(in) != len(h.token) || subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) != 1 {
+ in := strings.TrimSpace(body.Password)
+
+ initialized, initErr := h.isStoreInitialized(r.Context())
+ if initErr != nil {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ writeErrorf(w, "%v", initErr)
+ return
+ }
+ if !initialized {
+ w.WriteHeader(http.StatusConflict)
+ _, _ = w.Write([]byte(`{"error":"password has not been set"}`))
+ return
+ }
+
+ ok, err := h.store.VerifyPassword(r.Context(), in)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ writeErrorf(w, "password verification failed: %v", err)
+ return
+ }
+ if !ok {
w.WriteHeader(http.StatusUnauthorized)
- _, _ = w.Write([]byte(`{"error":"invalid token"}`))
+ _, _ = w.Write([]byte(`{"error":"invalid password"}`))
return
}
@@ -120,23 +166,105 @@ func (h *launcherAuthHandlers) handleLogout(w http.ResponseWriter, r *http.Reque
func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
- ok := false
+ authed := false
if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
- ok = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
+ authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
}
- if ok {
- _, _ = w.Write([]byte(`{"authenticated":true}`))
+ initialized, initErr := h.isStoreInitialized(r.Context())
+ if initErr != nil {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ writeErrorf(w, "%v", initErr)
return
}
resp := launcherAuthStatusResponse{
- Authenticated: false,
- TokenHelp: &h.tokenHelp,
+ Authenticated: authed,
+ Initialized: initialized,
}
enc, err := json.Marshal(resp)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
- _, _ = w.Write([]byte(`{"error":"internal error"}`))
+ writeErrorf(w, "marshal response failed: %v", err)
return
}
_, _ = w.Write(enc)
}
+
+// handleSetup sets or changes the dashboard password.
+//
+// Rules:
+// - If the store has no password yet, anyone who can reach the setup endpoint
+// may initialize the password.
+// - If a password is already set, the caller must hold a valid session cookie.
+func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+
+ if h.store == nil {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ if h.storeErr != nil {
+ writeErrorf(w, "password store unavailable: %v", h.storeErr)
+ } else {
+ _, _ = w.Write([]byte(`{"error":"password store not configured"}`))
+ }
+ return
+ }
+
+ initialized, initErr := h.isStoreInitialized(r.Context())
+ if initErr != nil {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ writeErrorf(w, "%v", initErr)
+ return
+ }
+
+ // If already initialized, require an active session (change-password flow).
+ if initialized {
+ authed := false
+ if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
+ authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
+ }
+ if !authed {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"error":"must be authenticated to change password"}`))
+ return
+ }
+ }
+
+ var body launcherAuthSetupBody
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":"invalid JSON"}`))
+ return
+ }
+
+ pw := strings.TrimSpace(body.Password)
+ if pw == "" {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":"password must not be empty"}`))
+ return
+ }
+ if pw != strings.TrimSpace(body.Confirm) {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":"passwords do not match"}`))
+ return
+ }
+ if len([]rune(pw)) < 8 {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":"password must be at least 8 characters"}`))
+ return
+ }
+
+ if err := h.store.SetPassword(r.Context(), pw); err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ writeErrorf(w, "failed to save password: %v", err)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"status":"ok"}`))
+}
+
+// writeErrorf writes a JSON error response with a formatted message.
+// json.Marshal is used to safely escape the message string.
+func writeErrorf(w http.ResponseWriter, format string, args ...any) {
+ msg, _ := json.Marshal(fmt.Sprintf(format, args...))
+ _, _ = w.Write([]byte(`{"error":` + string(msg) + `}`))
+}
diff --git a/web/backend/api/auth_test.go b/web/backend/api/auth_test.go
index d2624a440..f7f6037a0 100644
--- a/web/backend/api/auth_test.go
+++ b/web/backend/api/auth_test.go
@@ -2,7 +2,9 @@ package api
import (
"bytes"
+ "context"
"encoding/json"
+ "errors"
"net/http"
"net/http/httptest"
"strings"
@@ -12,23 +14,43 @@ import (
"github.com/sipeed/picoclaw/web/backend/middleware"
)
-func TestLauncherAuthLoginAndStatus(t *testing.T) {
- key := make([]byte, 32)
- for i := range key {
- key[i] = 0x55
+type fakePasswordStore struct {
+ initialized bool
+ password string
+ err error
+}
+
+func (s *fakePasswordStore) IsInitialized(context.Context) (bool, error) {
+ if s.err != nil {
+ return false, s.err
}
- const tok = "dashboard-test-token-9"
- sess := middleware.SessionCookieValue(key, tok)
+ return s.initialized, nil
+}
+
+func (s *fakePasswordStore) SetPassword(_ context.Context, plain string) error {
+ if s.err != nil {
+ return s.err
+ }
+ s.password = plain
+ s.initialized = true
+ return nil
+}
+
+func (s *fakePasswordStore) VerifyPassword(_ context.Context, plain string) (bool, error) {
+ if s.err != nil {
+ return false, s.err
+ }
+ return s.initialized && plain == s.password, nil
+}
+
+func TestLauncherAuthLoginAndStatus(t *testing.T) {
+ const password = "dashboard-test-password"
+ const sess = "session-cookie-value"
+ store := &fakePasswordStore{initialized: true, password: password}
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
- DashboardToken: tok,
- SessionCookie: sess,
- TokenHelp: LauncherAuthTokenHelp{
- EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
- LogFileAbs: "/tmp/launcher.log",
- TrayCopyMenu: true,
- ConsoleStdout: false,
- },
+ SessionCookie: sess,
+ PasswordStore: store,
})
t.Run("status_unauthenticated", func(t *testing.T) {
@@ -38,23 +60,20 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
t.Fatalf("status code = %d", rec.Code)
}
var body struct {
- Authenticated bool `json:"authenticated"`
- TokenHelp *LauncherAuthTokenHelp `json:"token_help"`
+ Authenticated bool `json:"authenticated"`
+ Initialized bool `json:"initialized"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
- if body.Authenticated || body.TokenHelp == nil {
- t.Fatalf("unexpected body: %+v", body)
- }
- if body.TokenHelp.EnvVarName != "PICOCLAW_LAUNCHER_TOKEN" || body.TokenHelp.LogFileAbs != "/tmp/launcher.log" {
- t.Fatalf("token_help = %+v", body.TokenHelp)
+ if body.Authenticated {
+ t.Fatalf("unexpected authenticated=true: %+v", body)
}
})
t.Run("login_ok", func(t *testing.T) {
rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"token":"`+tok+`"}`))
+ req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+password+`"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:12345"
mux.ServeHTTP(rec, req)
@@ -84,14 +103,152 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
})
}
-func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
- key := make([]byte, 32)
- sess := middleware.SessionCookieValue(key, "tok")
+func TestLauncherAuthUninitializedStoreRequiresSetup(t *testing.T) {
+ const sess = "session-cookie-value"
+ store := &fakePasswordStore{}
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
- DashboardToken: "tok",
- SessionCookie: sess,
- TokenHelp: LauncherAuthTokenHelp{EnvVarName: "PICOCLAW_LAUNCHER_TOKEN"},
+ SessionCookie: sess,
+ PasswordStore: store,
+ })
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/status", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status code = %d body=%s", rec.Code, rec.Body.String())
+ }
+
+ var body struct {
+ Authenticated bool `json:"authenticated"`
+ Initialized bool `json:"initialized"`
+ }
+ if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if body.Initialized {
+ t.Fatalf("initialized = true, want false before setup")
+ }
+ if body.Authenticated {
+ t.Fatalf("unexpected authenticated=true: %+v", body)
+ }
+
+ rec = httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"not-set-yet"}`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusConflict {
+ t.Fatalf("login before setup code = %d body=%s", rec.Code, rec.Body.String())
+ }
+
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(
+ http.MethodPost,
+ "/api/auth/setup",
+ strings.NewReader(`{"password":"12345678","confirm":"12345678"}`),
+ )
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String())
+ }
+
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"12345678"}`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("login after setup code = %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestLauncherAuthSetupRequiresSessionWhenInitialized(t *testing.T) {
+ const sess = "session-cookie-value"
+ store := &fakePasswordStore{initialized: true, password: "old-password"}
+ mux := http.NewServeMux()
+ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
+ SessionCookie: sess,
+ PasswordStore: store,
+ })
+
+ body := strings.NewReader(`{"password":"new-password","confirm":"new-password"}`)
+ req := httptest.NewRequest(http.MethodPost, "/api/auth/setup", body)
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("setup without session code = %d body=%s", rec.Code, rec.Body.String())
+ }
+
+ body = strings.NewReader(`{"password":"new-password","confirm":"new-password"}`)
+ req = httptest.NewRequest(http.MethodPost, "/api/auth/setup", body)
+ req.Header.Set("Content-Type", "application/json")
+ req.AddCookie(&http.Cookie{Name: middleware.LauncherDashboardCookieName, Value: sess})
+ rec = httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("setup with session code = %d body=%s", rec.Code, rec.Body.String())
+ }
+ if store.password != "new-password" {
+ t.Fatalf("password = %q, want new-password", store.password)
+ }
+}
+
+func TestLauncherAuthInitialSetupAllowsDirectSetup(t *testing.T) {
+ store := &fakePasswordStore{}
+ mux := http.NewServeMux()
+ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
+ SessionCookie: "session-cookie-value",
+ PasswordStore: store,
+ })
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/api/auth/setup",
+ strings.NewReader(`{"password":"12345678","confirm":"12345678"}`),
+ )
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("setup without grant code = %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestLauncherAuthStoreUnavailableFailsClosed(t *testing.T) {
+ mux := http.NewServeMux()
+ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
+ SessionCookie: "session-cookie-value",
+ StoreError: errors.New("open auth store"),
+ })
+
+ for _, tc := range []struct {
+ name string
+ method string
+ path string
+ body string
+ }{
+ {name: "status", method: http.MethodGet, path: "/api/auth/status"},
+ {name: "login", method: http.MethodPost, path: "/api/auth/login", body: `{"password":"password"}`},
+ {name: "setup", method: http.MethodPost, path: "/api/auth/setup", body: `{"password":"12345678","confirm":"12345678"}`},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
+ if tc.body != "" {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("code = %d body=%s", rec.Code, rec.Body.String())
+ }
+ })
+ }
+}
+
+func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
+ mux := http.NewServeMux()
+ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
+ SessionCookie: "session-cookie-value",
})
rec := httptest.NewRecorder()
@@ -118,18 +275,15 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
}
func TestLauncherAuthLoginRateLimit(t *testing.T) {
- key := make([]byte, 32)
- const tok = "rate-limit-tok-xxxxxxxx"
- sess := middleware.SessionCookieValue(key, tok)
+ store := &fakePasswordStore{initialized: true, password: "correct-password"}
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
- DashboardToken: tok,
- SessionCookie: sess,
- TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
+ SessionCookie: "session-cookie-value",
+ PasswordStore: store,
})
- // 11 failing logins by wrong token; each consumes allow() slot after valid JSON.
- wrongBody := `{"token":"wrong"}`
+ // 11 failing logins by wrong password; each consumes allow() slot after valid JSON.
+ wrongBody := `{"password":"wrong"}`
for i := 0; i < loginAttemptsPerIP; i++ {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody))
@@ -181,13 +335,9 @@ func TestReferrerPolicyMiddleware(t *testing.T) {
}
func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
- key := make([]byte, 32)
- sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
- DashboardToken: "tok",
- SessionCookie: sess,
- TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
+ SessionCookie: "session-cookie-value",
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
@@ -200,13 +350,9 @@ func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
}
func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) {
- key := make([]byte, 32)
- sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
- DashboardToken: "tok",
- SessionCookie: sess,
- TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
+ SessionCookie: "session-cookie-value",
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`))
diff --git a/web/backend/api/channels.go b/web/backend/api/channels.go
index dd4c9af3d..82cd54b72 100644
--- a/web/backend/api/channels.go
+++ b/web/backend/api/channels.go
@@ -3,6 +3,8 @@ package api
import (
"encoding/json"
"net/http"
+
+ "github.com/sipeed/picoclaw/pkg/config"
)
type channelCatalogItem struct {
@@ -30,9 +32,17 @@ var channelCatalog = []channelCatalogItem{
{Name: "irc", ConfigKey: "irc"},
}
+type channelConfigResponse struct {
+ Config any `json:"config"`
+ ConfiguredSecrets []string `json:"configured_secrets"`
+ ConfigKey string `json:"config_key"`
+ Variant string `json:"variant,omitempty"`
+}
+
// registerChannelRoutes binds read-only channel catalog endpoints to the ServeMux.
func (h *Handler) registerChannelRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog)
+ mux.HandleFunc("GET /api/channels/{name}/config", h.handleGetChannelConfig)
}
// handleListChannelCatalog returns the channels supported by backend.
@@ -44,3 +54,150 @@ func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Reques
"channels": channelCatalog,
})
}
+
+// handleGetChannelConfig returns safe channel config plus secret presence metadata.
+//
+// GET /api/channels/{name}/config
+func (h *Handler) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) {
+ channelName := r.PathValue("name")
+ item, ok := findChannelCatalogItem(channelName)
+ if !ok {
+ http.Error(w, "Channel not found", http.StatusNotFound)
+ return
+ }
+
+ cfg, err := config.LoadConfig(h.configPath)
+ if err != nil {
+ http.Error(w, "Failed to load config", http.StatusInternalServerError)
+ return
+ }
+
+ resp := buildChannelConfigResponse(cfg, item)
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ }
+}
+
+func findChannelCatalogItem(name string) (channelCatalogItem, bool) {
+ for _, item := range channelCatalog {
+ if item.Name == name {
+ return item, true
+ }
+ }
+ return channelCatalogItem{}, false
+}
+
+var channelSecretFieldMap = map[string][]string{
+ "weixin": {"token"},
+ "telegram": {"token"},
+ "discord": {"token"},
+ "slack": {"bot_token", "app_token"},
+ "feishu": {"app_secret", "encrypt_key", "verification_token"},
+ "dingtalk": {"client_secret"},
+ "line": {"channel_secret", "channel_access_token"},
+ "qq": {"app_secret"},
+ "onebot": {"access_token"},
+ "wecom": {"secret"},
+ "pico": {"token"},
+ "matrix": {"access_token"},
+ "irc": {"password", "nickserv_password", "sasl_password"},
+ "whatsapp": {},
+ "whatsapp_native": {},
+ "maixcam": {},
+}
+
+func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) channelConfigResponse {
+ resp := channelConfigResponse{
+ ConfiguredSecrets: []string{},
+ ConfigKey: item.ConfigKey,
+ Variant: item.Variant,
+ }
+
+ bc := cfg.Channels.Get(item.ConfigKey)
+ if bc == nil {
+ bc = defaultChannelConfig(item.ConfigKey)
+ if bc == nil {
+ resp.Config = map[string]any{}
+ return resp
+ }
+ }
+
+ // Detect configured secrets by checking the raw Settings JSON
+ secrets := detectConfiguredSecrets(bc.Settings, item.Name)
+ resp.ConfiguredSecrets = secrets
+
+ // Parse settings into a generic map for JSON response
+ settings := map[string]any{}
+ if len(bc.Settings) > 0 {
+ if err := json.Unmarshal(bc.Settings, &settings); err != nil {
+ resp.Config = map[string]any{}
+ return resp
+ }
+ }
+
+ // Remove secure fields from response
+ for _, key := range secrets {
+ delete(settings, key)
+ }
+ addChannelCommonConfig(settings, bc)
+ resp.Config = settings
+
+ return resp
+}
+
+func defaultChannelConfig(configKey string) *config.Channel {
+ return config.DefaultConfig().Channels.Get(configKey)
+}
+
+func addChannelCommonConfig(settings map[string]any, bc *config.Channel) {
+ settings["enabled"] = bc.Enabled
+ if len(bc.AllowFrom) > 0 {
+ settings["allow_from"] = []string(bc.AllowFrom)
+ }
+ if bc.ReasoningChannelID != "" {
+ settings["reasoning_channel_id"] = bc.ReasoningChannelID
+ }
+ if bc.GroupTrigger.MentionOnly || len(bc.GroupTrigger.Prefixes) > 0 {
+ settings["group_trigger"] = bc.GroupTrigger
+ }
+ if bc.Typing.Enabled {
+ settings["typing"] = bc.Typing
+ }
+ if bc.Placeholder.Enabled || len(bc.Placeholder.Text) > 0 {
+ settings["placeholder"] = bc.Placeholder
+ }
+}
+
+func detectConfiguredSecrets(settings config.RawNode, channelName string) []string {
+ var m map[string]any
+ if err := json.Unmarshal(settings, &m); err != nil {
+ return nil
+ }
+
+ fields, ok := channelSecretFieldMap[channelName]
+ if !ok {
+ return nil
+ }
+
+ var found []string
+ for _, key := range fields {
+ if val, exists := m[key]; exists {
+ switch v := val.(type) {
+ case string:
+ if v != "" {
+ found = append(found, key)
+ }
+ case map[string]any:
+ if s, ok := v["s"].(string); ok && s != "" {
+ found = append(found, key)
+ }
+ }
+ }
+ }
+ if found == nil {
+ return []string{}
+ }
+ return found
+}
diff --git a/web/backend/api/channels_test.go b/web/backend/api/channels_test.go
new file mode 100644
index 000000000..0208af8e7
--- /dev/null
+++ b/web/backend/api/channels_test.go
@@ -0,0 +1,195 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestHandleGetChannelConfig_ReturnsSecretPresenceWithoutLeakingSecrets(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ bc := cfg.Channels[config.ChannelFeishu]
+ bc.Enabled = true
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ bcfg := decoded.(*config.FeishuSettings)
+ bcfg.AppID = "cli_test_app"
+ bcfg.AppSecret = *config.NewSecureString("feishu-secret-from-security")
+ bc.AllowFrom = config.FlexibleStringSlice{"ou_test_user"}
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/channels/feishu/config", nil)
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf(
+ "GET /api/channels/feishu/config status = %d, want %d, body=%s",
+ rec.Code,
+ http.StatusOK,
+ rec.Body.String(),
+ )
+ }
+ if strings.Contains(rec.Body.String(), "feishu-secret-from-security") {
+ t.Fatalf("response leaked secret value: %s", rec.Body.String())
+ }
+
+ var resp struct {
+ Config map[string]any `json:"config"`
+ ConfiguredSecrets []string `json:"configured_secrets"`
+ ConfigKey string `json:"config_key"`
+ Variant string `json:"variant"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("json.Unmarshal() error = %v", err)
+ }
+
+ if got := resp.ConfigKey; got != "feishu" {
+ t.Fatalf("config_key = %q, want %q", got, "feishu")
+ }
+ if got := resp.Config["app_id"]; got != "cli_test_app" {
+ t.Fatalf("config.app_id = %#v, want %q", got, "cli_test_app")
+ }
+ if got := resp.Config["enabled"]; got != true {
+ t.Fatalf("config.enabled = %#v, want true", got)
+ }
+ allowFrom, ok := resp.Config["allow_from"].([]any)
+ if !ok || len(allowFrom) != 1 || allowFrom[0] != "ou_test_user" {
+ t.Fatalf("config.allow_from = %#v, want [\"ou_test_user\"]", resp.Config["allow_from"])
+ }
+ if _, exists := resp.Config["app_secret"]; exists {
+ t.Fatalf("config should omit app_secret, got %#v", resp.Config["app_secret"])
+ }
+ if len(resp.ConfiguredSecrets) != 1 || resp.ConfiguredSecrets[0] != "app_secret" {
+ t.Fatalf("configured_secrets = %#v, want [\"app_secret\"]", resp.ConfiguredSecrets)
+ }
+}
+
+func TestHandleGetChannelConfig_ReturnsNotFoundForUnknownChannel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/channels/not-a-channel/config", nil)
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("GET /api/channels/not-a-channel/config status = %d, want %d", rec.Code, http.StatusNotFound)
+ }
+}
+
+func TestHandleGetChannelConfig_ReturnsCommonFieldsWhenSettingsEmpty(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ bc := cfg.Channels[config.ChannelFeishu]
+ bc.Enabled = true
+ bc.AllowFrom = config.FlexibleStringSlice{"ou_common_user"}
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/channels/feishu/config", nil)
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf(
+ "GET /api/channels/feishu/config status = %d, want %d, body=%s",
+ rec.Code,
+ http.StatusOK,
+ rec.Body.String(),
+ )
+ }
+
+ var resp struct {
+ Config map[string]any `json:"config"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("json.Unmarshal() error = %v", err)
+ }
+ if got := resp.Config["enabled"]; got != true {
+ t.Fatalf("config.enabled = %#v, want true", got)
+ }
+ allowFrom, ok := resp.Config["allow_from"].([]any)
+ if !ok || len(allowFrom) != 1 || allowFrom[0] != "ou_common_user" {
+ t.Fatalf("config.allow_from = %#v, want [\"ou_common_user\"]", resp.Config["allow_from"])
+ }
+}
+
+func TestHandleGetChannelConfig_ReturnsDefaultShapeForMissingChannel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ delete(cfg.Channels, config.ChannelIRC)
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/channels/irc/config", nil)
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf(
+ "GET /api/channels/irc/config status = %d, want %d, body=%s",
+ rec.Code,
+ http.StatusOK,
+ rec.Body.String(),
+ )
+ }
+
+ var resp struct {
+ Config map[string]any `json:"config"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("json.Unmarshal() error = %v", err)
+ }
+ if got := resp.Config["server"]; got != "" {
+ t.Fatalf("config.server = %#v, want empty string", got)
+ }
+ if got := resp.Config["nick"]; got != "picoclaw" {
+ t.Fatalf("config.nick = %#v, want %q", got, "picoclaw")
+ }
+ if got := resp.Config["enabled"]; got != false {
+ t.Fatalf("config.enabled = %#v, want false", got)
+ }
+}
diff --git a/web/backend/api/config.go b/web/backend/api/config.go
index 0add7594d..afcd3f74e 100644
--- a/web/backend/api/config.go
+++ b/web/backend/api/config.go
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
+ "reflect"
"regexp"
"strings"
@@ -20,6 +21,14 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns)
}
+func (h *Handler) applyRuntimeLogLevel() {
+ if h.debug {
+ logger.SetLevel(logger.DEBUG)
+ return
+ }
+ logger.SetLevelFromString(config.ResolveGatewayLogLevel(h.configPath))
+}
+
// handleGetConfig returns the complete system configuration.
//
// GET /api/config
@@ -47,8 +56,22 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
}
defer r.Body.Close()
+ var raw map[string]any
+ if err = json.Unmarshal(body, &raw); err != nil {
+ http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
+ return
+ }
+ if err = normalizeChannelArrayFields(raw); err != nil {
+ http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest)
+ return
+ }
+ normalizedBody, err := json.Marshal(raw)
+ if err != nil {
+ http.Error(w, "Failed to normalize config payload", http.StatusBadRequest)
+ return
+ }
var cfg config.Config
- if err = json.Unmarshal(body, &cfg); err != nil {
+ if err = json.Unmarshal(normalizedBody, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
@@ -63,6 +86,7 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
return
}
+ applyConfigSecretsFromMap(&cfg, raw)
if errs := validateConfig(&cfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
@@ -74,13 +98,14 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
return
}
- logger.Infof("configuration updated successfully")
-
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
+ h.applyRuntimeLogLevel()
+ logger.Infof("configuration updated successfully")
+
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
@@ -124,7 +149,6 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
-
existing, err := json.Marshal(cfg)
if err != nil {
http.Error(w, "Failed to serialize current config", http.StatusInternalServerError)
@@ -139,6 +163,10 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
// Recursively merge patch into base
mergeMap(base, patch)
+ if err = normalizeChannelArrayFields(base); err != nil {
+ http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest)
+ return
+ }
// Convert merged map back to Config struct
merged, err := json.Marshal(base)
@@ -159,6 +187,7 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
return
}
+ applyConfigSecretsFromMap(&newCfg, base)
if errs := validateConfig(&newCfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
@@ -175,6 +204,9 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
return
}
+ h.applyRuntimeLogLevel()
+ logger.Infof("configuration updated successfully")
+
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
@@ -259,26 +291,54 @@ func validateConfig(cfg *config.Config) []string {
}
// Pico channel: token required when enabled
- if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token.String() == "" {
- errs = append(errs, "channels.pico.token is required when pico channel is enabled")
+ {
+ bc := cfg.Channels.GetByType(config.ChannelPico)
+ if bc != nil && bc.Enabled {
+ if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
+ if c, ok := decoded.(*config.PicoSettings); ok && c.Token.String() == "" {
+ errs = append(errs, "channels.pico.token is required when pico channel is enabled")
+ }
+ }
+ }
}
// Telegram: token required when enabled
- if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token.String() == "" {
- errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
+ {
+ bc := cfg.Channels.GetByType(config.ChannelTelegram)
+ if bc != nil && bc.Enabled {
+ if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
+ if c, ok := decoded.(*config.TelegramSettings); ok && c.Token.String() == "" {
+ errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
+ }
+ }
+ }
}
// Discord: token required when enabled
- if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token.String() == "" {
- errs = append(errs, "channels.discord.token is required when discord channel is enabled")
+ {
+ bc := cfg.Channels.GetByType(config.ChannelDiscord)
+ if bc != nil && bc.Enabled {
+ if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
+ if c, ok := decoded.(*config.DiscordSettings); ok && c.Token.String() == "" {
+ errs = append(errs, "channels.discord.token is required when discord channel is enabled")
+ }
+ }
+ }
}
- if cfg.Channels.WeCom.Enabled {
- if cfg.Channels.WeCom.BotID == "" {
- errs = append(errs, "channels.wecom.bot_id is required when wecom channel is enabled")
- }
- if cfg.Channels.WeCom.Secret.String() == "" {
- errs = append(errs, "channels.wecom.secret is required when wecom channel is enabled")
+ {
+ bc := cfg.Channels.GetByType(config.ChannelWeCom)
+ if bc != nil && bc.Enabled {
+ if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
+ if c, ok := decoded.(*config.WeComSettings); ok {
+ if c.BotID == "" {
+ errs = append(errs, "channels.wecom.bot_id is required when wecom channel is enabled")
+ }
+ if c.Secret.String() == "" {
+ errs = append(errs, "channels.wecom.secret is required when wecom channel is enabled")
+ }
+ }
+ }
}
}
@@ -325,3 +385,374 @@ func mergeMap(dst, src map[string]any) {
}
}
}
+
+func asMapField(value map[string]any, key string) (map[string]any, bool) {
+ raw, exists := value[key]
+ if !exists {
+ return nil, false
+ }
+ m, isMap := raw.(map[string]any)
+ return m, isMap
+}
+
+var (
+ allowFromHiddenCharsRe = regexp.MustCompile("[\u200B\u200C\u200D\u200E\u200F\u202A-\u202E\u2060-\u2069\uFEFF]")
+ allowFromSplitRe = regexp.MustCompile("[,\uFF0C、;;\r\n\t]+")
+ conservativeSplitRe = regexp.MustCompile("[,\uFF0C\r\n\t]+")
+)
+
+type stringArrayParserOptions struct {
+ stripHiddenChars bool
+}
+
+func normalizeChannelArrayFields(raw map[string]any) error {
+ channelsMap, hasChannels := asMapField(raw, "channel_list")
+ if !hasChannels {
+ return nil
+ }
+
+ defaultCfg := config.DefaultConfig()
+ for channelName, rawChannel := range channelsMap {
+ chMap, ok := rawChannel.(map[string]any)
+ if !ok {
+ continue
+ }
+
+ if rawAllowFrom, exists := chMap["allow_from"]; exists {
+ normalized, err := normalizeStringArrayValue(rawAllowFrom, stringArrayParserOptions{
+ stripHiddenChars: true,
+ })
+ if err != nil {
+ return fmt.Errorf("channel_list.%s.allow_from: %w", channelName, err)
+ }
+ chMap["allow_from"] = normalized
+ }
+
+ if groupTrigger, ok := asMapField(chMap, "group_trigger"); ok {
+ if rawPrefixes, exists := groupTrigger["prefixes"]; exists {
+ normalized, err := normalizeStringArrayValue(rawPrefixes, stringArrayParserOptions{})
+ if err != nil {
+ return fmt.Errorf("channel_list.%s.group_trigger.prefixes: %w", channelName, err)
+ }
+ groupTrigger["prefixes"] = normalized
+ }
+ }
+
+ settingsMap, hasSettings := asMapField(chMap, "settings")
+ if !hasSettings {
+ continue
+ }
+
+ settingsType := channelSettingsType(defaultCfg, channelName, chMap)
+ if settingsType == nil {
+ continue
+ }
+
+ for i := range settingsType.NumField() {
+ field := settingsType.Field(i)
+ if !field.IsExported() || !isStringSliceType(field.Type) {
+ continue
+ }
+ jsonKey := strings.Split(field.Tag.Get("json"), ",")[0]
+ if jsonKey == "" || jsonKey == "-" {
+ continue
+ }
+ rawValue, exists := settingsMap[jsonKey]
+ if !exists {
+ continue
+ }
+
+ options := stringArrayParserOptions{}
+ if jsonKey == "allow_from" {
+ options.stripHiddenChars = true
+ }
+ normalized, err := normalizeStringArrayValue(rawValue, options)
+ if err != nil {
+ return fmt.Errorf("channel_list.%s.settings.%s: %w", channelName, jsonKey, err)
+ }
+ settingsMap[jsonKey] = normalized
+ }
+ }
+ return nil
+}
+
+func channelSettingsType(
+ defaultCfg *config.Config,
+ channelName string,
+ channelMap map[string]any,
+) reflect.Type {
+ if channelType, _ := channelMap["type"].(string); channelType != "" {
+ if bc := defaultCfg.Channels.GetByType(channelType); bc != nil {
+ if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
+ return derefType(reflect.TypeOf(decoded))
+ }
+ }
+ }
+
+ if bc := defaultCfg.Channels.Get(channelName); bc != nil {
+ if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
+ return derefType(reflect.TypeOf(decoded))
+ }
+ }
+
+ return nil
+}
+
+func derefType(typ reflect.Type) reflect.Type {
+ for typ != nil && typ.Kind() == reflect.Ptr {
+ typ = typ.Elem()
+ }
+ return typ
+}
+
+func isStringSliceType(typ reflect.Type) bool {
+ typ = derefType(typ)
+ return typ != nil && typ.Kind() == reflect.Slice && typ.Elem().Kind() == reflect.String
+}
+
+func normalizeStringArrayValue(value any, options stringArrayParserOptions) ([]string, error) {
+ switch typed := value.(type) {
+ case nil:
+ return nil, nil
+ case string:
+ return parseStringArrayValue(typed, options), nil
+ case float64:
+ return normalizeStringArrayItems([]string{fmt.Sprintf("%.0f", typed)}, options), nil
+ case []string:
+ return normalizeStringArrayItems(typed, options), nil
+ case []any:
+ items := make([]string, 0, len(typed))
+ for _, item := range typed {
+ switch raw := item.(type) {
+ case string:
+ items = append(items, raw)
+ case float64:
+ items = append(items, fmt.Sprintf("%.0f", raw))
+ default:
+ return nil, fmt.Errorf("unsupported list item type %T", item)
+ }
+ }
+ return normalizeStringArrayItems(items, options), nil
+ default:
+ return nil, fmt.Errorf("unsupported list field type %T", value)
+ }
+}
+
+func parseStringArrayValue(raw string, options stringArrayParserOptions) []string {
+ if strings.TrimSpace(raw) == "" {
+ return []string{}
+ }
+ splitRe := conservativeSplitRe
+ if options.stripHiddenChars {
+ splitRe = allowFromSplitRe
+ }
+ return normalizeStringArrayItems(splitRe.Split(raw, -1), options)
+}
+
+func normalizeStringArrayItems(items []string, options stringArrayParserOptions) []string {
+ result := make([]string, 0, len(items))
+ seen := make(map[string]struct{}, len(items))
+ for _, item := range items {
+ normalized := item
+ if options.stripHiddenChars {
+ normalized = allowFromHiddenCharsRe.ReplaceAllString(normalized, "")
+ }
+ normalized = strings.TrimSpace(normalized)
+ if normalized == "" {
+ continue
+ }
+ if _, exists := seen[normalized]; exists {
+ continue
+ }
+ seen[normalized] = struct{}{}
+ result = append(result, normalized)
+ }
+ if len(result) == 0 {
+ return []string{}
+ }
+ return result
+}
+
+func getSecretString(m map[string]any, key string) (string, bool) {
+ if raw, exists := m[key]; exists {
+ s, isString := raw.(string)
+ if isString {
+ return s, true
+ }
+ }
+ if raw, exists := m["_"+key]; exists {
+ s, isString := raw.(string)
+ if isString {
+ return s, true
+ }
+ }
+ return "", false
+}
+
+func applyConfigSecretsFromMap(cfg *config.Config, raw map[string]any) {
+ channelsMap, hasChannels := asMapField(raw, "channel_list")
+ if !hasChannels {
+ return
+ }
+
+ for chName, chData := range channelsMap {
+ chMap, ok := chData.(map[string]any)
+ if !ok {
+ continue
+ }
+ bc := cfg.Channels.Get(chName)
+ if bc == nil {
+ continue
+ }
+ decoded, err := bc.GetDecoded()
+ if err != nil || decoded == nil {
+ continue
+ }
+ rv := reflect.ValueOf(decoded)
+ if rv.Kind() == reflect.Ptr {
+ rv = rv.Elem()
+ }
+ if rv.Kind() != reflect.Struct {
+ continue
+ }
+ // Channel-specific settings live under the "settings" key in the raw map
+ settingsMap := chMap
+ if sm, hasSettings := asMapField(chMap, "settings"); hasSettings {
+ settingsMap = sm
+ }
+ applySecureStringsToStruct(rv, settingsMap)
+ }
+
+ // Handle tools secrets
+ tools, hasTools := asMapField(raw, "tools")
+ if !hasTools {
+ return
+ }
+ skills, hasSkills := asMapField(tools, "skills")
+ if !hasSkills {
+ return
+ }
+ if github, hasGithub := asMapField(skills, "github"); hasGithub {
+ if token, hasToken := getSecretString(github, "token"); hasToken {
+ cfg.Tools.Skills.Github.Token.Set(token)
+ }
+ }
+ if registries, hasRegistries := asMapField(skills, "registries"); hasRegistries {
+ for registryName, rawRegistry := range registries {
+ registryMap, ok := rawRegistry.(map[string]any)
+ if !ok {
+ continue
+ }
+ if authToken, hasAuthToken := getSecretString(registryMap, "auth_token"); hasAuthToken {
+ registryCfg, _ := cfg.Tools.Skills.Registries.Get(registryName)
+ registryCfg.AuthToken.Set(authToken)
+ cfg.Tools.Skills.Registries.Set(registryName, registryCfg)
+ }
+ }
+ return
+ }
+
+ registriesList, hasRegistries := skills["registries"].([]any)
+ if !hasRegistries {
+ return
+ }
+ for _, rawRegistry := range registriesList {
+ registryMap, ok := rawRegistry.(map[string]any)
+ if !ok {
+ continue
+ }
+ name, _ := registryMap["name"].(string)
+ if name == "" {
+ continue
+ }
+ if authToken, hasAuthToken := getSecretString(registryMap, "auth_token"); hasAuthToken {
+ registryCfg, _ := cfg.Tools.Skills.Registries.Get(name)
+ registryCfg.AuthToken.Set(authToken)
+ cfg.Tools.Skills.Registries.Set(name, registryCfg)
+ }
+ }
+}
+
+// applySecureStringsToStruct walks a struct and applies SecureString fields
+// from the matching keys in rawMap. It recurses into nested maps and slices.
+func applySecureStringsToStruct(rv reflect.Value, rawMap map[string]any) {
+ rt := rv.Type()
+ for jsonKey, rawVal := range rawMap {
+ for i := range rt.NumField() {
+ f := rt.Field(i)
+ if !f.IsExported() {
+ continue
+ }
+ tag := f.Tag.Get("json")
+ name := strings.Split(tag, ",")[0]
+ if name != jsonKey {
+ continue
+ }
+ sf := rv.Field(i)
+ if !sf.CanSet() {
+ continue
+ }
+ // Direct SecureString field
+ if s, ok := rawVal.(string); ok {
+ if f.Type == reflect.TypeOf(config.SecureString{}) {
+ sf.Set(reflect.ValueOf(*config.NewSecureString(s)))
+ } else if f.Type == reflect.TypeOf(&config.SecureString{}) {
+ sf.Set(reflect.ValueOf(config.NewSecureString(s)))
+ }
+ continue
+ }
+ // Recurse into nested struct
+ if sf.Kind() == reflect.Struct {
+ if nested, ok := rawVal.(map[string]any); ok {
+ applySecureStringsToStruct(sf, nested)
+ }
+ continue
+ }
+ // Recurse into map fields (e.g., map[string]SomeStruct)
+ if sf.Kind() == reflect.Map && sf.Type().Elem().Kind() == reflect.Struct {
+ if nestedMap, ok := rawVal.(map[string]any); ok {
+ for mapKey, mapVal := range nestedMap {
+ nested, ok := mapVal.(map[string]any)
+ if !ok {
+ continue
+ }
+ elemType := sf.Type().Elem()
+ // Get existing element or create a new zero value
+ var elem reflect.Value
+ existing := sf.MapIndex(reflect.ValueOf(mapKey))
+ if existing.IsValid() {
+ if existing.Kind() == reflect.Interface {
+ existing = existing.Elem()
+ }
+ if existing.Kind() == reflect.Ptr && !existing.IsNil() {
+ elem = reflect.New(elemType)
+ elem.Elem().Set(existing.Elem())
+ } else if existing.Kind() == reflect.Struct {
+ elem = reflect.New(elemType)
+ elem.Elem().Set(existing)
+ }
+ }
+ if !elem.IsValid() {
+ elem = reflect.New(elemType)
+ }
+ applySecureStringsToStruct(elem.Elem(), nested)
+ sf.SetMapIndex(reflect.ValueOf(mapKey), elem.Elem())
+ }
+ }
+ continue
+ }
+ // Recurse into slice elements that are structs
+ if sf.Kind() == reflect.Slice && sf.Type().Elem().Kind() == reflect.Struct {
+ if sliceRaw, ok := rawVal.([]any); ok {
+ for idx, elemRaw := range sliceRaw {
+ if nested, ok := elemRaw.(map[string]any); ok {
+ if idx < sf.Len() {
+ applySecureStringsToStruct(sf.Index(idx), nested)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go
index 644284849..8377c2eca 100644
--- a/web/backend/api/config_test.go
+++ b/web/backend/api/config_test.go
@@ -6,11 +6,42 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
)
+func assertGatewayLogLevelApplied(t *testing.T, method, body string, want logger.LogLevel) {
+ t.Helper()
+
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ initialLevel := logger.GetLevel()
+ logger.SetLevel(logger.INFO)
+ t.Cleanup(func() {
+ logger.SetLevel(initialLevel)
+ })
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(method, "/api/config", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s /api/config status = %d, want %d, body=%s", method, rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if got := logger.GetLevel(); got != want {
+ t.Fatalf("logger.GetLevel() = %v, want %v", got, want)
+ }
+}
+
func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -20,7 +51,7 @@ func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testin
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
-"version": 1,
+"version": 3,
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace"
@@ -143,6 +174,409 @@ func TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *tes
}
}
+func TestHandlePatchConfig_SavesChannelListSettingsPatch(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "channel_list": {
+ "feishu": {
+ "enabled": true,
+ "allow_from": ["ou_patch_user"],
+ "settings": {
+ "app_id": "cli_patch_app",
+ "app_secret": "patch-secret",
+ "is_lark": true
+ }
+ }
+ }
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ bc := cfg.Channels[config.ChannelFeishu]
+ if !bc.Enabled {
+ t.Fatal("feishu should be enabled after PATCH")
+ }
+ if len(bc.AllowFrom) != 1 || bc.AllowFrom[0] != "ou_patch_user" {
+ t.Fatalf("feishu allow_from = %#v, want [\"ou_patch_user\"]", bc.AllowFrom)
+ }
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ feishuCfg := decoded.(*config.FeishuSettings)
+ if got := feishuCfg.AppID; got != "cli_patch_app" {
+ t.Fatalf("feishu app_id = %q, want %q", got, "cli_patch_app")
+ }
+ if got := feishuCfg.AppSecret.String(); got != "patch-secret" {
+ t.Fatalf("feishu app_secret = %q, want %q", got, "patch-secret")
+ }
+ if !feishuCfg.IsLark {
+ t.Fatal("feishu is_lark should be true after PATCH")
+ }
+}
+
+func TestHandlePatchConfig_NormalizesStringChannelArrayFields(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "channel_list": {
+ "pico": {
+ "type": "pico",
+ "allow_from": " ou_a\u200b,\u2060ou_b\tou_c\u202e,ou_a ",
+ "group_trigger": {
+ "prefixes": "/,!;\n?,/"
+ },
+ "settings": {
+ "allow_origins": "https://a.example.com,http://localhost:5173,https://a.example.com"
+ }
+ },
+ "irc": {
+ "type": "irc",
+ "settings": {
+ "channels": "#ops,\n#dev,\n#ops",
+ "request_caps": "multi-prefix,echo-message\tbatch,multi-prefix"
+ }
+ }
+ }
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+
+ picoChannel := cfg.Channels[config.ChannelPico]
+ if len(picoChannel.AllowFrom) != 3 ||
+ picoChannel.AllowFrom[0] != "ou_a" ||
+ picoChannel.AllowFrom[1] != "ou_b" ||
+ picoChannel.AllowFrom[2] != "ou_c" {
+ t.Fatalf("pico allow_from = %#v, want [\"ou_a\", \"ou_b\", \"ou_c\"]", picoChannel.AllowFrom)
+ }
+ if len(picoChannel.GroupTrigger.Prefixes) != 3 ||
+ picoChannel.GroupTrigger.Prefixes[0] != "/" ||
+ picoChannel.GroupTrigger.Prefixes[1] != "!;" ||
+ picoChannel.GroupTrigger.Prefixes[2] != "?" {
+ t.Fatalf(
+ "pico group_trigger.prefixes = %#v, want [\"/\", \"!;\", \"?\"]",
+ picoChannel.GroupTrigger.Prefixes,
+ )
+ }
+
+ decoded, err := picoChannel.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() pico error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ if len(picoCfg.AllowOrigins) != 2 ||
+ picoCfg.AllowOrigins[0] != "https://a.example.com" ||
+ picoCfg.AllowOrigins[1] != "http://localhost:5173" {
+ t.Fatalf(
+ "pico allow_origins = %#v, want [\"https://a.example.com\", \"http://localhost:5173\"]",
+ picoCfg.AllowOrigins,
+ )
+ }
+
+ ircChannel := cfg.Channels[config.ChannelIRC]
+ decoded, err = ircChannel.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() irc error = %v", err)
+ }
+ ircCfg := decoded.(*config.IRCSettings)
+ if len(ircCfg.Channels) != 2 ||
+ ircCfg.Channels[0] != "#ops" ||
+ ircCfg.Channels[1] != "#dev" {
+ t.Fatalf("irc channels = %#v, want [\"#ops\", \"#dev\"]", ircCfg.Channels)
+ }
+ if len(ircCfg.RequestCaps) != 3 ||
+ ircCfg.RequestCaps[0] != "multi-prefix" ||
+ ircCfg.RequestCaps[1] != "echo-message" ||
+ ircCfg.RequestCaps[2] != "batch" {
+ t.Fatalf(
+ "irc request_caps = %#v, want [\"multi-prefix\", \"echo-message\", \"batch\"]",
+ ircCfg.RequestCaps,
+ )
+ }
+}
+
+func TestHandlePatchConfig_NormalizesSingleNumericAllowFrom(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "channel_list": {
+ "telegram": {
+ "type": "telegram",
+ "allow_from": 123456
+ }
+ }
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ telegramChannel := cfg.Channels[config.ChannelTelegram]
+ if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "123456" {
+ t.Fatalf("telegram allow_from = %#v, want [\"123456\"]", telegramChannel.AllowFrom)
+ }
+}
+
+func TestHandlePatchConfig_RejectsInvalidChannelArrayFields(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ telegramChannel := cfg.Channels[config.ChannelTelegram]
+ telegramChannel.AllowFrom = config.FlexibleStringSlice{"existing-user"}
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ tests := []struct {
+ name string
+ body string
+ }{
+ {
+ name: "object allow_from",
+ body: `{
+ "channel_list": {
+ "telegram": {
+ "type": "telegram",
+ "allow_from": {"id": "bad"}
+ }
+ }
+ }`,
+ },
+ {
+ name: "boolean allow_from",
+ body: `{
+ "channel_list": {
+ "telegram": {
+ "type": "telegram",
+ "allow_from": true
+ }
+ }
+ }`,
+ },
+ {
+ name: "object settings array",
+ body: `{
+ "channel_list": {
+ "irc": {
+ "type": "irc",
+ "settings": {
+ "channels": {"name": "#ops"}
+ }
+ }
+ }
+ }`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(tt.body))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf(
+ "PATCH /api/config status = %d, want %d, body=%s",
+ rec.Code,
+ http.StatusBadRequest,
+ rec.Body.String(),
+ )
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ telegramChannel := cfg.Channels[config.ChannelTelegram]
+ if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "existing-user" {
+ t.Fatalf("telegram allow_from = %#v, want unchanged [\"existing-user\"]", telegramChannel.AllowFrom)
+ }
+ })
+ }
+}
+
+func TestHandlePatchConfig_ClearingAllowFromDoesNotLeaveEmptyStringItem(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ feishuChannel := cfg.Channels[config.ChannelFeishu]
+ feishuChannel.Enabled = true
+ feishuChannel.AllowFrom = config.FlexibleStringSlice{"ou_existing_user"}
+ decoded, err := feishuChannel.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ feishuCfg := decoded.(*config.FeishuSettings)
+ feishuCfg.AppID = "cli_existing_app"
+ feishuCfg.AppSecret = *config.NewSecureString("existing-secret")
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "channel_list": {
+ "feishu": {
+ "enabled": true,
+ "allow_from": "",
+ "settings": {
+ "app_id": "cli_existing_app"
+ }
+ }
+ }
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err = config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ feishuChannel = cfg.Channels[config.ChannelFeishu]
+ if len(feishuChannel.AllowFrom) != 0 {
+ t.Fatalf("feishu allow_from = %#v, want empty slice", feishuChannel.AllowFrom)
+ }
+
+ configData, err := os.ReadFile(configPath)
+ if err != nil {
+ t.Fatalf("ReadFile(configPath) error = %v", err)
+ }
+ if strings.Contains(string(configData), `"allow_from": [""]`) {
+ t.Fatalf("config file should not contain empty-string allow_from item: %s", string(configData))
+ }
+}
+
+func TestHandlePatchConfig_CreatesMissingChannelWithTypeAndSecret(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ delete(cfg.Channels, config.ChannelIRC)
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "channel_list": {
+ "irc": {
+ "enabled": true,
+ "type": "irc",
+ "settings": {
+ "server": "irc.example.com",
+ "password": "irc-patch-password"
+ }
+ }
+ }
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err = config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ bc := cfg.Channels[config.ChannelIRC]
+ if bc == nil {
+ t.Fatal("irc channel should exist after PATCH")
+ }
+ if got := bc.Type; got != config.ChannelIRC {
+ t.Fatalf("irc type = %q, want %q", got, config.ChannelIRC)
+ }
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ ircCfg := decoded.(*config.IRCSettings)
+ if got := ircCfg.Server; got != "irc.example.com" {
+ t.Fatalf("irc server = %q, want %q", got, "irc.example.com")
+ }
+ if got := ircCfg.Password.String(); got != "irc-patch-password" {
+ t.Fatalf("irc password = %q, want %q", got, "irc-patch-password")
+ }
+ configData, err := os.ReadFile(configPath)
+ if err != nil {
+ t.Fatalf("ReadFile(configPath) error = %v", err)
+ }
+ if bytes.Contains(configData, []byte("irc-patch-password")) {
+ t.Fatalf("config file leaked irc password: %s", string(configData))
+ }
+}
+
// setupPicoEnabledEnv creates a test environment with Pico channel enabled and
// its token stored only in .security.yml (not in the JSON payload).
func setupPicoEnabledEnv(t *testing.T) (string, func()) {
@@ -166,8 +600,14 @@ func setupPicoEnabledEnv(t *testing.T) (string, func()) {
APIKeys: config.SimpleSecureStrings("sk-default"),
}}
cfg.Agents.Defaults.ModelName = "custom-default"
- cfg.Channels.Pico.Enabled = true
- cfg.Channels.Pico.Token = *config.NewSecureString("test-pico-token")
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ bc.Enabled = true
+ picoCfg.Token = *config.NewSecureString("test-pico-token")
configPath := filepath.Join(tmp, "config.json")
if err := config.SaveConfig(configPath, cfg); err != nil {
@@ -251,6 +691,162 @@ func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) {
}
}
+func TestHandleUpdateConfig_AppliesGatewayLogLevel(t *testing.T) {
+ assertGatewayLogLevelApplied(t, http.MethodPut, `{
+ "version": 1,
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model_name": "custom-default"
+ }
+ },
+ "gateway": {
+ "log_level": "error"
+ },
+ "model_list": [
+ {
+ "model_name": "custom-default",
+ "model": "openai/gpt-4o",
+ "api_keys": ["sk-default"]
+ }
+ ]
+ }`, logger.ERROR)
+}
+
+func TestHandlePatchConfig_AppliesGatewayLogLevel(t *testing.T) {
+ assertGatewayLogLevelApplied(t, http.MethodPatch, `{
+ "gateway": {
+ "log_level": "debug"
+ }
+ }`, logger.DEBUG)
+}
+
+func TestHandlePatchConfig_PreservesDebugFlagOverride(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ initialLevel := logger.GetLevel()
+ logger.SetLevel(logger.INFO)
+ t.Cleanup(func() {
+ logger.SetLevel(initialLevel)
+ })
+
+ h := NewHandler(configPath)
+ h.SetDebug(true)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "gateway": {
+ "log_level": "error"
+ }
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if got := logger.GetLevel(); got != logger.DEBUG {
+ t.Fatalf("logger.GetLevel() = %v, want %v", got, logger.DEBUG)
+ }
+}
+
+func TestHandlePatchConfig_SavesDiscordTokenFromPayload(t *testing.T) {
+ t.Skip("TODO: fix this test")
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "channel_list": [
+ {
+ "name":"discord",
+ "enabled": true,
+ "token": "discord-test-token"
+ }
+ ]
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ bc := cfg.Channels[config.ChannelDiscord]
+ if !bc.Enabled {
+ t.Fatal("discord should be enabled after PATCH")
+ }
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ if got := decoded.(*config.DiscordSettings).Token.String(); got != "discord-test-token" {
+ t.Fatalf("discord token = %q, want %q", got, "discord-test-token")
+ }
+}
+
+func TestHandlePatchConfig_DoesNotPersistShadowRegistryAuthTokenField(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "tools": {
+ "skills": {
+ "registries": {
+ "github": {
+ "_auth_token": "ghp-shadow-token"
+ }
+ }
+ }
+ }
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
+ if !ok {
+ t.Fatal("github registry missing after PATCH")
+ }
+ if got := githubRegistry.AuthToken.String(); got != "ghp-shadow-token" {
+ t.Fatalf("github registry auth token = %q, want %q", got, "ghp-shadow-token")
+ }
+ if got := githubRegistry.BaseURL; got != "https://github.com" {
+ t.Fatalf("github registry base_url = %q, want %q", got, "https://github.com")
+ }
+
+ rawConfig, err := os.ReadFile(configPath)
+ if err != nil {
+ t.Fatalf("ReadFile(configPath) error = %v", err)
+ }
+ if strings.Contains(string(rawConfig), "_auth_token") {
+ t.Fatalf("config.json should not persist _auth_token shadow field, got:\n%s", string(rawConfig))
+ }
+}
+
func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -443,3 +1039,190 @@ func TestHandleTestCommandPatterns_InvalidJSON(t *testing.T) {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}
+
+func TestApplyConfigSecretsFromMap_TelegramToken(t *testing.T) {
+ cfg := config.DefaultConfig()
+ bc := cfg.Channels["telegram"]
+ bc.Enabled = true
+ // Pre-decode so extend is populated
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ tgCfg := decoded.(*config.TelegramSettings)
+ tgCfg.Token = *config.NewSecureString("original-token")
+
+ raw := map[string]any{
+ "channel_list": map[string]any{
+ "telegram": map[string]any{
+ "enabled": true,
+ "token": "secret-from-api",
+ },
+ },
+ }
+
+ applyConfigSecretsFromMap(cfg, raw)
+
+ if got := tgCfg.Token.String(); got != "secret-from-api" {
+ t.Fatalf("telegram token = %q, want %q", got, "secret-from-api")
+ }
+}
+
+func TestApplyConfigSecretsFromMap_TeamsWebhook(t *testing.T) {
+ // applyConfigSecretsFromMap recurses into nested maps to find
+ // SecureString fields at any depth (e.g. webhook_url inside webhooks map).
+ cfg := config.DefaultConfig()
+ bc := &config.Channel{Enabled: true, Type: config.ChannelTeamsWebHook}
+ cfg.Channels["teams_webhook"] = bc
+ target := &config.TeamsWebhookSettings{
+ Webhooks: map[string]config.TeamsWebhookTarget{
+ "default": {
+ WebhookURL: *config.NewSecureString("https://example.com/hook1"),
+ Title: "Default",
+ },
+ },
+ }
+ if err := bc.Decode(target); err != nil {
+ t.Fatalf("Decode() error = %v", err)
+ }
+
+ raw := map[string]any{
+ "channel_list": map[string]any{
+ "teams_webhook": map[string]any{
+ "enabled": true,
+ "settings": map[string]any{
+ "webhooks": map[string]any{
+ "default": map[string]any{
+ "webhook_url": "https://example.com/hook-updated",
+ "title": "Default Updated",
+ },
+ },
+ },
+ },
+ },
+ }
+
+ applyConfigSecretsFromMap(cfg, raw)
+
+ // Verify the decoded struct has the updated SecureString value
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ twCfg, ok := decoded.(*config.TeamsWebhookSettings)
+ if !ok {
+ t.Fatalf("expected *TeamsWebhookSettings, got %T", decoded)
+ }
+
+ hookURL := twCfg.Webhooks["default"].WebhookURL
+ if got := hookURL.String(); got != "https://example.com/hook-updated" {
+ t.Fatalf("webhook_url = %q, want %q", got, "https://example.com/hook-updated")
+ }
+ // Note: title is a plain string, not a SecureString, so it is NOT updated
+ // by applyConfigSecretsFromMap (only secure fields are handled).
+}
+
+func TestApplyConfigSecretsFromMap_MultipleChannels(t *testing.T) {
+ cfg := config.DefaultConfig()
+
+ // Setup telegram
+ bc := cfg.Channels["telegram"]
+ bc.Enabled = true
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() telegram error = %v", err)
+ }
+ tgCfg := decoded.(*config.TelegramSettings)
+ tgCfg.Token = *config.NewSecureString("old-telegram-token")
+
+ // Setup discord
+ bc = cfg.Channels["discord"]
+ bc.Enabled = true
+ decoded, err = bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() discord error = %v", err)
+ }
+ discCfg := decoded.(*config.DiscordSettings)
+ discCfg.Token = *config.NewSecureString("old-discord-token")
+
+ raw := map[string]any{
+ "channel_list": map[string]any{
+ "telegram": map[string]any{
+ "enabled": true,
+ "settings": map[string]any{
+ "token": "new-telegram-token",
+ },
+ },
+ "discord": map[string]any{
+ "enabled": true,
+ "settings": map[string]any{
+ "token": "new-discord-token",
+ },
+ },
+ },
+ }
+
+ applyConfigSecretsFromMap(cfg, raw)
+
+ if got := tgCfg.Token.String(); got != "new-telegram-token" {
+ t.Fatalf("telegram token = %q, want %q", got, "new-telegram-token")
+ }
+ if got := discCfg.Token.String(); got != "new-discord-token" {
+ t.Fatalf("discord token = %q, want %q", got, "new-discord-token")
+ }
+}
+
+func TestApplyConfigSecretsFromMap_SkipsNonStringValues(t *testing.T) {
+ cfg := config.DefaultConfig()
+ bc := cfg.Channels["telegram"]
+ bc.Enabled = true
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ tgCfg := decoded.(*config.TelegramSettings)
+ tgCfg.Token = *config.NewSecureString("original-token")
+
+ raw := map[string]any{
+ "channel_list": map[string]any{
+ "telegram": map[string]any{
+ "enabled": true,
+ "token": 12345, // not a string, should be skipped
+ },
+ },
+ }
+
+ applyConfigSecretsFromMap(cfg, raw)
+
+ if got := tgCfg.Token.String(); got != "original-token" {
+ t.Fatalf("telegram token = %q, want %q", got, "original-token")
+ }
+}
+
+func TestApplyConfigSecretsFromMap_ChannelNotDecodedYet(t *testing.T) {
+ cfg := config.DefaultConfig()
+ bc := cfg.Channels["telegram"]
+ bc.Enabled = true
+ // Don't decode — let the function handle lazy decoding
+ bc.Type = config.ChannelTelegram
+
+ raw := map[string]any{
+ "channel_list": map[string]any{
+ "telegram": map[string]any{
+ "enabled": true,
+ "token": "lazy-decoded-token",
+ },
+ },
+ }
+
+ applyConfigSecretsFromMap(cfg, raw)
+
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ tgCfg := decoded.(*config.TelegramSettings)
+ if got := tgCfg.Token.String(); got != "lazy-decoded-token" {
+ t.Fatalf("telegram token = %q, want %q", got, "lazy-decoded-token")
+ }
+}
diff --git a/web/backend/api/exec_nonwindows.go b/web/backend/api/exec_nonwindows.go
new file mode 100644
index 000000000..0dc3c0e94
--- /dev/null
+++ b/web/backend/api/exec_nonwindows.go
@@ -0,0 +1,11 @@
+//go:build !windows
+
+package api
+
+import "os/exec"
+
+func launcherExecCommand(name string, args ...string) *exec.Cmd {
+ return exec.Command(name, args...)
+}
+
+func applyLauncherProcAttrs(_ *exec.Cmd) {}
diff --git a/web/backend/api/exec_windows.go b/web/backend/api/exec_windows.go
new file mode 100644
index 000000000..86d3193a0
--- /dev/null
+++ b/web/backend/api/exec_windows.go
@@ -0,0 +1,24 @@
+//go:build windows
+
+package api
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+func launcherExecCommand(name string, args ...string) *exec.Cmd {
+ cmd := exec.Command(name, args...)
+ applyLauncherProcAttrs(cmd)
+ return cmd
+}
+
+func applyLauncherProcAttrs(cmd *exec.Cmd) {
+ if cmd == nil {
+ return
+ }
+ if cmd.SysProcAttr == nil {
+ cmd.SysProcAttr = &syscall.SysProcAttr{}
+ }
+ cmd.SysProcAttr.HideWindow = true
+}
diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go
index 2621b722b..45f7e6912 100644
--- a/web/backend/api/gateway.go
+++ b/web/backend/api/gateway.go
@@ -2,6 +2,7 @@ package api
import (
"bufio"
+ "bytes"
"encoding/json"
"errors"
"fmt"
@@ -10,7 +11,9 @@ import (
"net/http"
"os"
"os/exec"
+ "reflect"
"runtime"
+ "sort"
"strconv"
"strings"
"sync"
@@ -20,6 +23,8 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/netbind"
+ ppid "github.com/sipeed/picoclaw/pkg/pid"
"github.com/sipeed/picoclaw/web/backend/utils"
)
@@ -33,16 +38,72 @@ var gateway = struct {
runtimeStatus string
startupDeadline time.Time
logs *LogBuffer
+ pidData *ppid.PidFileData // pid file data read from picoclaw.pid.json
+ picoToken string // cached raw pico token for upstream gateway proxy injection
}{
runtimeStatus: "stopped",
logs: NewLogBuffer(200),
}
+// refreshPicoTokensLocked reads the pico token from config and caches it.
+// Caller must hold gateway.mu (or be sole writer).
+func refreshPicoTokensLocked(configPath string) {
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ return
+ }
+ var picoCfg config.PicoSettings
+ if bc := cfg.Channels.GetByType(config.ChannelPico); bc != nil {
+ decoded, err := bc.GetDecoded()
+ if err == nil && decoded != nil {
+ if p, ok := decoded.(*config.PicoSettings); ok {
+ picoCfg = *p
+ }
+ }
+ }
+ gateway.picoToken = picoCfg.Token.String()
+}
+
+// ensurePicoTokenCachedLocked lazily fills the in-memory pico token cache when
+// the launcher has already discovered a running gateway via pidData, but has
+// not yet refreshed the token into memory.
+func ensurePicoTokenCachedLocked(configPath string) {
+ if gateway.picoToken != "" {
+ return
+ }
+ refreshPicoTokensLocked(configPath)
+}
+
+func (h *Handler) gatewayCommandArgs() []string {
+ args := []string{"gateway", "-E"}
+ if h.debug {
+ args = append(args, "-d")
+ }
+ return args
+}
+
+const (
+ protocolKey = "Sec-Websocket-Protocol"
+ tokenPrefix = "token."
+)
+
+// picoGatewayProtocol returns the gateway-facing pico subprotocol that the
+// launcher should inject when proxying browser traffic upstream.
+func picoGatewayProtocol() string {
+ gateway.mu.Lock()
+ defer gateway.mu.Unlock()
+ if gateway.picoToken == "" {
+ return ""
+ }
+ return tokenPrefix + gateway.picoToken
+}
+
var (
gatewayStartupWindow = 15 * time.Second
gatewayRestartGracePeriod = 5 * time.Second
gatewayRestartForceKillWindow = 3 * time.Second
gatewayRestartPollInterval = 100 * time.Millisecond
+ gatewayExecCommand = exec.Command
)
var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) {
@@ -50,16 +111,31 @@ var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response,
return client.Get(url)
}
-// getGatewayHealth checks the gateway health endpoint and returns the status response
+var gatewayProcessMatcher = isLikelyGatewayProcess
+
+// getGatewayHealth checks the gateway health endpoint and returns the status response.
// Returns (*health.StatusResponse, statusCode, error). If error is not nil, the other values are not valid.
func (h *Handler) getGatewayHealth(cfg *config.Config, timeout time.Duration) (*health.StatusResponse, int, error) {
- port := 18790
- if cfg != nil && cfg.Gateway.Port != 0 {
- port = cfg.Gateway.Port
+ // Prefer port/host from pidData when available.
+ var port int
+ var host string
+ gateway.mu.Lock()
+ if d := gateway.pidData; d != nil && d.Port > 0 {
+ port = d.Port
+ host = gatewayProbeHost(d.Host)
+ }
+ gateway.mu.Unlock()
+ if port == 0 {
+ port = 18790
+ if cfg != nil && cfg.Gateway.Port != 0 {
+ port = cfg.Gateway.Port
+ }
+ }
+ if host == "" {
+ host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
}
- probeHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
- url := "http://" + net.JoinHostPort(probeHost, strconv.Itoa(port)) + "/health"
+ url := "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/health"
return getGatewayHealthByURL(url, timeout)
}
@@ -79,6 +155,150 @@ func getGatewayHealthByURL(url string, timeout time.Duration) (*health.StatusRes
return &healthResponse, resp.StatusCode, nil
}
+// isLikelyGatewayProcess returns whether PID appears to be a picoclaw gateway
+// process plus whether inspection was conclusive on this platform/environment.
+func isLikelyGatewayProcess(pid int) (bool, bool) {
+ if pid <= 0 {
+ return false, true
+ }
+
+ if runtime.GOOS == "windows" {
+ psCmd := fmt.Sprintf(
+ `$p=Get-CimInstance Win32_Process -Filter "ProcessId = %d"; if ($null -eq $p) { "" } else { $p.CommandLine }`,
+ pid,
+ )
+ out, err := launcherExecCommand("powershell", "-NoProfile", "-NonInteractive", "-Command", psCmd).Output()
+ if err == nil {
+ cmdline := strings.TrimSpace(string(out))
+ if cmdline != "" {
+ return looksLikeGatewayCommandLine(cmdline), true
+ }
+ }
+
+ // Fallback: determine only whether the process still exists.
+ out, err = launcherExecCommand("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH").Output()
+ if err != nil {
+ return false, false
+ }
+ line := strings.ToLower(strings.TrimSpace(string(out)))
+ if line == "" {
+ return false, true
+ }
+ // A CSV row means the process exists, but may have a custom executable
+ // name we cannot classify here.
+ if strings.HasPrefix(line, "\"") {
+ if strings.Contains(line, "\"picoclaw.exe\"") {
+ return true, true
+ }
+ return false, true
+ }
+ if strings.Contains(line, "no tasks are running") {
+ return false, true
+ }
+ return false, true
+ }
+
+ out, err := launcherExecCommand("ps", "-o", "command=", "-p", strconv.Itoa(pid)).Output()
+ if err != nil {
+ return false, false
+ }
+ cmdline := strings.ToLower(strings.TrimSpace(string(out)))
+ if cmdline == "" {
+ return false, true
+ }
+ return looksLikeGatewayCommandLine(cmdline), true
+}
+
+// looksLikeGatewayCommandLine checks whether a process command line likely
+// represents "picoclaw gateway ..." regardless of executable filename.
+func looksLikeGatewayCommandLine(cmdline string) bool {
+ fields := strings.Fields(strings.ToLower(strings.TrimSpace(cmdline)))
+ if len(fields) == 0 {
+ return false
+ }
+ for _, f := range fields {
+ token := strings.Trim(f, `"'`)
+ if token == "gateway" || strings.HasSuffix(token, "/gateway") || strings.HasSuffix(token, `\gateway`) {
+ return true
+ }
+ }
+ return false
+}
+
+func (h *Handler) getGatewayHealthForPidData(
+ pidData *ppid.PidFileData,
+ cfg *config.Config,
+ timeout time.Duration,
+) (*health.StatusResponse, int, error) {
+ if pidData == nil {
+ return nil, 0, errors.New("nil pid data")
+ }
+
+ port := pidData.Port
+ if port == 0 {
+ port = 18790
+ if cfg != nil && cfg.Gateway.Port != 0 {
+ port = cfg.Gateway.Port
+ }
+ }
+
+ host := gatewayProbeHost(strings.TrimSpace(pidData.Host))
+ if host == "" {
+ host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
+ }
+ if host == "" {
+ host = netbind.ResolveAdaptiveLoopbackHost()
+ }
+
+ url := "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/health"
+ return getGatewayHealthByURL(url, timeout)
+}
+
+func (h *Handler) validateGatewayPidData(
+ pidData *ppid.PidFileData,
+ cfg *config.Config,
+) (ok bool, decisive bool, reason string) {
+ if pidData == nil || pidData.PID <= 0 {
+ return false, true, "invalid pid data"
+ }
+
+ if gatewayProcess, inspected := gatewayProcessMatcher(pidData.PID); inspected {
+ if !gatewayProcess {
+ return false, true, "pid process command is not picoclaw gateway"
+ }
+ return true, true, ""
+ }
+
+ healthResp, statusCode, err := h.getGatewayHealthForPidData(pidData, cfg, 800*time.Millisecond)
+ if err != nil {
+ return false, false, fmt.Sprintf("health probe failed: %v", err)
+ }
+ if statusCode != http.StatusOK {
+ return false, false, fmt.Sprintf("health endpoint returned status %d", statusCode)
+ }
+ if healthResp.PID > 0 && healthResp.PID != pidData.PID {
+ return false, true, fmt.Sprintf("health pid mismatch: pidFile=%d, health=%d", pidData.PID, healthResp.PID)
+ }
+ return true, true, ""
+}
+
+func (h *Handler) sanitizeGatewayPidData(pidData *ppid.PidFileData, cfg *config.Config) *ppid.PidFileData {
+ if pidData == nil {
+ return nil
+ }
+
+ ok, decisive, reason := h.validateGatewayPidData(pidData, cfg)
+ if ok {
+ return pidData
+ }
+
+ logger.Warnf("ignore pid file for PID %d: %s", pidData.PID, reason)
+ if decisive && ppid.RemovePidFileIfPID(globalConfigDir(), pidData.PID) {
+ logger.Warnf("removed stale pid file for PID %d", pidData.PID)
+ }
+ return nil
+}
+
// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux.
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
@@ -92,30 +312,33 @@ func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
// TryAutoStartGateway checks whether gateway start preconditions are met and
// starts it when possible. Intended to be called by the backend at startup.
func (h *Handler) TryAutoStartGateway() {
- // Check if gateway is already running via health endpoint
- cfg, cfgErr := config.LoadConfig(h.configPath)
- if cfgErr == nil && cfg != nil {
- healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second)
- if err == nil && statusCode == http.StatusOK {
- // Gateway is already running, attach to the existing process
- pid := healthResp.Pid
- gateway.mu.Lock()
- defer gateway.mu.Unlock()
- ready, reason, err := h.gatewayStartReady()
- if err != nil {
- logger.ErrorC("gateway", fmt.Sprintf("Skip auto-starting gateway: %v", err))
- return
- }
- if !ready {
- logger.InfoC("gateway", fmt.Sprintf("Skip auto-starting gateway: %s", reason))
- return
- }
- _, err = h.startGatewayLocked("starting", pid)
- if err != nil {
- logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err))
- }
+ // Check PID file first to detect an already-running gateway.
+ pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil)
+ if pidData != nil {
+ gateway.mu.Lock()
+ ready, reason, err := h.gatewayStartReady()
+ if err != nil {
+ logger.ErrorC("gateway", fmt.Sprintf("Skip auto-starting gateway: %v", err))
+ gateway.mu.Unlock()
return
}
+ logger.Infof("ready: %v, reason: %s", ready, reason)
+ if !ready {
+ logger.InfoC("gateway", fmt.Sprintf("Skip auto-starting gateway: %s", reason))
+ gateway.mu.Unlock()
+ return
+ }
+ pid := pidData.PID
+ _, err = h.startGatewayLocked("starting", pid)
+ if err != nil {
+ logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err))
+ } else {
+ gateway.pidData = pidData
+ refreshPicoTokensLocked(h.configPath)
+ logger.InfoC("gateway", fmt.Sprintf("Attached to running gateway via PID file (PID: %d)", pid))
+ }
+ gateway.mu.Unlock()
+ return
}
gateway.mu.Lock()
@@ -159,6 +382,9 @@ func (h *Handler) gatewayStartReady() (bool, string, error) {
if modelCfg == nil {
return false, fmt.Sprintf("default model %q is invalid", modelName), nil
}
+ if !defaultModelAllowedForModelConfig(modelCfg) {
+ return false, fmt.Sprintf("default model %q is not usable for chat", modelName), nil
+ }
if !hasModelConfiguration(modelCfg) {
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
@@ -211,6 +437,10 @@ func computeConfigSignature(cfg *config.Config) string {
}
if cfg.Tools.Web.Enabled {
toolSignatures = append(toolSignatures, "web")
+ webConfig, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(cfg.Tools.Web)))
+ if err == nil {
+ parts = append(parts, "webcfg:"+string(webConfig))
+ }
}
if cfg.Tools.WebFetch.Enabled {
toolSignatures = append(toolSignatures, "web_fetch")
@@ -254,9 +484,175 @@ func computeConfigSignature(cfg *config.Config) string {
if len(toolSignatures) > 0 {
parts = append(parts, "tools:"+strings.Join(toolSignatures, ","))
}
+ channelSignatures := computeChannelSignatures(cfg.Channels)
+ if len(channelSignatures) > 0 {
+ parts = append(parts, "channels:"+strings.Join(channelSignatures, ","))
+ }
return strings.Join(parts, ";")
}
+func computeChannelSignatures(channels config.ChannelsConfig) []string {
+ if len(channels) == 0 {
+ return nil
+ }
+
+ keys := make([]string, 0, len(channels))
+ for name := range channels {
+ keys = append(keys, name)
+ }
+ sort.Strings(keys)
+
+ signatures := make([]string, 0, len(keys))
+ for _, name := range keys {
+ channel := channels[name]
+ if channel == nil {
+ signatures = append(signatures, name+":")
+ continue
+ }
+
+ payload := struct {
+ Enabled bool `json:"enabled"`
+ Type string `json:"type"`
+ AllowFrom config.FlexibleStringSlice `json:"allow_from,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id,omitempty"`
+ GroupTrigger config.GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing config.TypingConfig `json:"typing,omitempty"`
+ Placeholder config.PlaceholderConfig `json:"placeholder,omitempty"`
+ Settings json.RawMessage `json:"settings,omitempty"`
+ }{
+ Enabled: channel.Enabled,
+ Type: channel.Type,
+ AllowFrom: channel.AllowFrom,
+ ReasoningChannelID: channel.ReasoningChannelID,
+ GroupTrigger: channel.GroupTrigger,
+ Typing: channel.Typing,
+ Placeholder: channel.Placeholder,
+ Settings: normalizeChannelSettings(channel),
+ }
+
+ encoded, err := json.Marshal(payload)
+ if err != nil {
+ signatures = append(signatures, name+":")
+ continue
+ }
+ signatures = append(signatures, name+":"+string(encoded))
+ }
+
+ return signatures
+}
+
+func normalizeChannelSettings(channel *config.Channel) json.RawMessage {
+ if channel == nil {
+ return nil
+ }
+
+ decoded, err := channel.GetDecoded()
+ if err == nil && decoded != nil {
+ normalized, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(decoded)))
+ if err == nil {
+ return normalized
+ }
+ }
+
+ return normalizeRawJSON(channel.Settings)
+}
+
+func normalizeRawJSON(raw config.RawNode) json.RawMessage {
+ if len(raw) == 0 {
+ return nil
+ }
+
+ var value any
+ if err := json.Unmarshal(raw, &value); err != nil {
+ return bytes.TrimSpace(raw)
+ }
+
+ normalized, err := json.Marshal(value)
+ if err != nil {
+ return bytes.TrimSpace(raw)
+ }
+ return normalized
+}
+
+func canonicalizeSignatureValue(value reflect.Value) any {
+ if !value.IsValid() {
+ return nil
+ }
+
+ if value.CanInterface() {
+ switch typed := value.Interface().(type) {
+ case config.SecureString:
+ return typed.String()
+ case *config.SecureString:
+ if typed == nil {
+ return ""
+ }
+ return typed.String()
+ case config.SecureStrings:
+ return typed.Values()
+ case *config.SecureStrings:
+ if typed == nil {
+ return nil
+ }
+ return typed.Values()
+ }
+ }
+
+ switch value.Kind() {
+ case reflect.Interface, reflect.Pointer:
+ if value.IsNil() {
+ return nil
+ }
+ return canonicalizeSignatureValue(value.Elem())
+ case reflect.Struct:
+ result := make(map[string]any)
+ valueType := value.Type()
+ for i := 0; i < value.NumField(); i++ {
+ field := valueType.Field(i)
+ if field.PkgPath != "" {
+ continue
+ }
+ tag := field.Tag.Get("json")
+ name := field.Name
+ if tag != "" {
+ if comma := strings.Index(tag, ","); comma >= 0 {
+ tag = tag[:comma]
+ }
+ if tag == "-" {
+ continue
+ }
+ if tag != "" {
+ name = tag
+ }
+ }
+ result[name] = canonicalizeSignatureValue(value.Field(i))
+ }
+ return result
+ case reflect.Slice, reflect.Array:
+ length := value.Len()
+ result := make([]any, 0, length)
+ for i := 0; i < length; i++ {
+ result = append(result, canonicalizeSignatureValue(value.Index(i)))
+ }
+ return result
+ case reflect.Map:
+ if value.Type().Key().Kind() != reflect.String {
+ return value.Interface()
+ }
+ result := make(map[string]any, value.Len())
+ iter := value.MapRange()
+ for iter.Next() {
+ result[iter.Key().String()] = canonicalizeSignatureValue(iter.Value())
+ }
+ return result
+ default:
+ if value.CanInterface() {
+ return value.Interface()
+ }
+ return nil
+ }
+}
+
func gatewayRestartRequiredBySignature(bootSignature, currentSignature, gatewayStatus string) bool {
if gatewayStatus != "running" {
return false
@@ -283,7 +679,13 @@ func isCmdProcessAliveLocked(cmd *exec.Cmd) bool {
return true
}
- return cmd.Process.Signal(syscall.Signal(0)) == nil
+ err := cmd.Process.Signal(syscall.Signal(0))
+ if err == nil {
+ return true
+ }
+ var errno syscall.Errno
+ // EPERM means the process exists but cannot be signaled by this user.
+ return errors.As(err, &errno) && errno == syscall.EPERM
}
func setGatewayRuntimeStatusLocked(status string) {
@@ -327,6 +729,15 @@ func gatewayStatusWithoutHealthLocked() string {
return "error"
}
if gateway.runtimeStatus == "running" {
+ // For attached processes there is no waiter goroutine; degrade stale
+ // running state once the tracked process exits.
+ if !isCmdProcessAliveLocked(gateway.cmd) {
+ gateway.cmd = nil
+ gateway.owned = false
+ gateway.bootDefaultModel = ""
+ gateway.bootConfigSignature = ""
+ return "stopped"
+ }
return "running"
}
if gateway.runtimeStatus == "error" {
@@ -383,6 +794,11 @@ func stopGatewayLocked() (int, error) {
}
pid := gateway.cmd.Process.Pid
+ if !gateway.owned {
+ if isGateway, inspected := gatewayProcessMatcher(pid); inspected && !isGateway {
+ return pid, fmt.Errorf("refuse to stop non-gateway process (PID %d)", pid)
+ }
+ }
// Send SIGTERM for graceful shutdown (SIGKILL on Windows)
var sigErr error
@@ -400,6 +816,7 @@ func stopGatewayLocked() (int, error) {
gateway.cmd = nil
gateway.owned = false
gateway.bootDefaultModel = ""
+ gateway.pidData = nil
setGatewayRuntimeStatusLocked("stopped")
return pid, nil
@@ -452,6 +869,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
pid = existingPid
gateway.cmd = nil // Clear first to ensure clean state
if err = attachToGatewayProcessLocked(pid, cfg); err != nil {
+ logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to existing gateway (PID %d): %v", pid, err))
return 0, err
}
@@ -461,8 +879,10 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
// Start new process
// Locate the picoclaw executable
execPath := utils.FindPicoclawBinary()
+ logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath))
- cmd = exec.Command(execPath, "gateway", "-E")
+ cmd = gatewayExecCommand(execPath, h.gatewayCommandArgs()...)
+ applyLauncherProcAttrs(cmd)
cmd.Env = os.Environ()
// Forward the launcher's config path via the environment variable that
// GetConfigPath() already reads, so the gateway sub-process uses the same
@@ -470,8 +890,9 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
if h.configPath != "" {
cmd.Env = append(cmd.Env, config.EnvConfig+"="+h.configPath)
}
- if host := h.gatewayHostOverride(); host != "" {
- cmd.Env = append(cmd.Env, config.EnvGatewayHost+"="+host)
+ gatewayHostOverride := h.gatewayHostOverride()
+ if gatewayHostOverride != "" {
+ cmd.Env = append(cmd.Env, config.EnvGatewayHost+"="+gatewayHostOverride)
}
stdoutPipe, err := cmd.StdoutPipe()
@@ -488,10 +909,21 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
gateway.logs.Reset()
// Ensure Pico Channel is configured before starting gateway
- if _, err := h.EnsurePicoChannel(""); err != nil {
+ changed, err := h.EnsurePicoChannel()
+ if err != nil {
logger.ErrorC("gateway", fmt.Sprintf("Warning: failed to ensure pico channel: %v", err))
// Non-fatal: gateway can still start without pico channel
}
+ // Refresh cached pico token in case EnsurePicoChannel generated a new one.
+ // Already holding gateway.mu from caller.
+ if changed {
+ refreshPicoTokensLocked(h.configPath)
+ cfg, err = config.LoadConfig(h.configPath)
+ if err != nil {
+ return 0, fmt.Errorf("failed to reload config after ensuring pico channel: %w", err)
+ }
+ defaultModelName = strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
+ }
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("failed to start gateway: %w", err)
@@ -529,8 +961,9 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
gateway.mu.Unlock()
}()
- // Start a goroutine to probe health and update the runtime state once ready.
+ // Start a goroutine to probe pidFile and health, update runtime state once ready.
go func() {
+ healthConfirmed := false
for i := 0; i < 30; i++ { // try for up to 15 seconds
time.Sleep(500 * time.Millisecond)
gateway.mu.Lock()
@@ -539,19 +972,46 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
if !stillOurs {
return
}
+
+ // Poll for pidFile first — once available we have port/host/token.
+ if pd := ppid.ReadPidFileWithCheck(globalConfigDir()); pd != nil && pd.PID == pid {
+ gateway.mu.Lock()
+ if gateway.cmd == cmd {
+ gateway.pidData = pd
+ var picoCfg config.PicoSettings
+ if bc := cfg.Channels.GetByType(config.ChannelPico); bc != nil {
+ decoded, err := bc.GetDecoded()
+ if err == nil && decoded != nil {
+ if p, ok := decoded.(*config.PicoSettings); ok {
+ picoCfg = *p
+ }
+ }
+ }
+ gateway.picoToken = picoCfg.Token.String()
+ setGatewayRuntimeStatusLocked("running")
+ }
+ gateway.mu.Unlock()
+ logger.InfoC("gateway", fmt.Sprintf("Gateway pidFile detected (PID: %d, port: %d)", pd.PID, pd.Port))
+ return
+ }
+
+ // Fallback: probe health endpoint to confirm liveness.
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
continue
}
- healthResp, statusCode, err := h.getGatewayHealth(cfg, 1*time.Second)
- if err == nil && statusCode == http.StatusOK && healthResp.Pid == pid {
- // Verify the health endpoint returns the expected pid
+ _, statusCode, err := h.getGatewayHealth(cfg, 1*time.Second)
+ if err == nil && statusCode == http.StatusOK {
gateway.mu.Lock()
if gateway.cmd == cmd {
setGatewayRuntimeStatusLocked("running")
}
gateway.mu.Unlock()
- return
+ if !healthConfirmed {
+ healthConfirmed = true
+ logger.InfoC("gateway", "Gateway health endpoint reachable; waiting for pid file")
+ }
+ continue
}
}
}()
@@ -563,49 +1023,47 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
//
// POST /api/gateway/start
func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
- // Prevent duplicate starts by checking health endpoint
- cfg, cfgErr := config.LoadConfig(h.configPath)
- if cfgErr == nil && cfg != nil {
- healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second)
- if err == nil && statusCode == http.StatusOK {
- // Gateway is already running, attach to the existing process
- pid := healthResp.Pid
- gateway.mu.Lock()
- ready, reason, err := h.gatewayStartReady()
- if err != nil {
- gateway.mu.Unlock()
- http.Error(
- w,
- fmt.Sprintf("Failed to validate gateway start conditions: %v", err),
- http.StatusInternalServerError,
- )
- return
- }
- if !ready {
- gateway.mu.Unlock()
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusBadRequest)
- json.NewEncoder(w).Encode(map[string]any{
- "status": "precondition_failed",
- "message": reason,
- })
- return
- }
- _, err = h.startGatewayLocked("starting", pid)
+ // Check PID file first to detect an already-running gateway.
+ pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil)
+ if pidData != nil {
+ pid := pidData.PID
+ gateway.mu.Lock()
+ ready, reason, err := h.gatewayStartReady()
+ if err != nil {
+ gateway.mu.Unlock()
+ http.Error(
+ w,
+ fmt.Sprintf("Failed to validate gateway start conditions: %v", err),
+ http.StatusInternalServerError,
+ )
+ return
+ }
+ if !ready {
gateway.mu.Unlock()
- if err != nil {
- logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err))
- http.Error(w, fmt.Sprintf("Failed to attach to gateway: %v", err), http.StatusInternalServerError)
- return
- }
w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
+ w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
- "status": "ok",
- "pid": pid,
+ "status": "precondition_failed",
+ "message": reason,
})
return
}
+ _, err = h.startGatewayLocked("starting", pid)
+ if err != nil {
+ gateway.mu.Unlock()
+ logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err))
+ http.Error(w, fmt.Sprintf("Failed to attach to gateway: %v", err), http.StatusInternalServerError)
+ return
+ }
+ gateway.pidData = pidData
+ gateway.mu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]any{
+ "status": "ok",
+ "pid": pid,
+ })
+ return
}
gateway.mu.Lock()
@@ -692,9 +1150,22 @@ func (h *Handler) RestartGateway() (int, error) {
gateway.mu.Lock()
previousCmd := gateway.cmd
+ previousOwned := gateway.owned
setGatewayRuntimeStatusLocked("restarting")
gateway.mu.Unlock()
+ if previousCmd != nil && previousCmd.Process != nil && !previousOwned {
+ if isGateway, inspected := gatewayProcessMatcher(previousCmd.Process.Pid); inspected && !isGateway {
+ logger.Warnf("refuse restarting non-gateway process (PID: %d)", previousCmd.Process.Pid)
+ gateway.mu.Lock()
+ if gateway.cmd == previousCmd {
+ setGatewayRuntimeStatusLocked("running")
+ }
+ gateway.mu.Unlock()
+ return 0, fmt.Errorf("refuse to restart non-gateway process (PID %d)", previousCmd.Process.Pid)
+ }
+ }
+
if err = stopGatewayProcessForRestart(previousCmd); err != nil {
gateway.mu.Lock()
if gateway.cmd == previousCmd {
@@ -805,66 +1276,42 @@ func (h *Handler) gatewayStatusData() map[string]any {
}
}
- // Probe health endpoint to get pid and status
- healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second)
- if err != nil {
+ // Primary detection: read PID file and check if process is alive.
+ pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), cfg)
+ if pidData != nil {
gateway.mu.Lock()
- data["gateway_status"] = gatewayStatusWithoutHealthLocked()
- gateway.mu.Unlock()
- logger.ErrorC("gateway", fmt.Sprintf("Gateway health check failed: %v", err))
- } else {
- if statusCode != http.StatusOK {
- logger.WarnC("gateway", fmt.Sprintf("Gateway health status: %d", statusCode))
- gateway.mu.Lock()
- setGatewayRuntimeStatusLocked("error")
- gateway.mu.Unlock()
- data["gateway_status"] = "error"
- data["status_code"] = statusCode
- } else {
- gateway.mu.Lock()
- setGatewayRuntimeStatusLocked("running")
- if gateway.cmd == nil || gateway.cmd.Process == nil || gateway.cmd.Process.Pid != healthResp.Pid {
- oldPid := "none"
- if gateway.cmd != nil && gateway.cmd.Process != nil {
- oldPid = fmt.Sprintf("%d", gateway.cmd.Process.Pid)
- }
- logger.InfoC(
- "gateway",
- fmt.Sprintf(
- "Detected new gateway PID (old: %s, new: %d), attempting to attach",
- oldPid,
- healthResp.Pid,
- ),
- )
-
- if err := attachToGatewayProcessLocked(healthResp.Pid, cfg); err != nil {
- // Failed to find the process, treat as error
- setGatewayRuntimeStatusLocked("error")
- data["gateway_status"] = "error"
- data["pid"] = healthResp.Pid
- logger.ErrorC(
- "gateway",
- fmt.Sprintf("Failed to attach to new gateway process (PID: %d): %v", healthResp.Pid, err),
- )
- } else {
- // Successfully attached, update response data
- bootDefaultModel := gateway.bootDefaultModel
- if bootDefaultModel != "" {
- data["boot_default_model"] = bootDefaultModel
- }
- data["gateway_status"] = "running"
- data["pid"] = healthResp.Pid
- }
- }
-
- bootDefaultModel := gateway.bootDefaultModel
- if bootDefaultModel != "" {
- data["boot_default_model"] = bootDefaultModel
- }
- data["gateway_status"] = "running"
- data["pid"] = healthResp.Pid
- gateway.mu.Unlock()
+ gateway.pidData = pidData
+ if pidData.Version != "" {
+ data["gateway_version"] = pidData.Version
}
+ setGatewayRuntimeStatusLocked("running")
+
+ // Attach if we don't already track this PID.
+ if gateway.cmd == nil || gateway.cmd.Process == nil || gateway.cmd.Process.Pid != pidData.PID {
+ _ = attachToGatewayProcessLocked(pidData.PID, cfg)
+ }
+
+ bootDefaultModel := gateway.bootDefaultModel
+ if bootDefaultModel != "" {
+ data["boot_default_model"] = bootDefaultModel
+ }
+ data["gateway_status"] = "running"
+ data["pid"] = pidData.PID
+ gateway.mu.Unlock()
+ } else {
+ // Intentionally skip health probe here; the startup goroutine
+ // (startGatewayLocked) already handles liveness detection via
+ // pidFile polling and health fallback.
+ gateway.mu.Lock()
+ status := gatewayStatusWithoutHealthLocked()
+ data["gateway_status"] = status
+ // Keep last known pidData while gateway is still in a transient
+ // running state; otherwise websocket proxy may lose auth token
+ // during short pid-file races.
+ if status == "stopped" || status == "error" {
+ gateway.pidData = nil
+ }
+ gateway.mu.Unlock()
}
gatewayStatus, _ := data["gateway_status"].(string)
diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go
index 6190f0c7c..03af7a9d3 100644
--- a/web/backend/api/gateway_host.go
+++ b/web/backend/api/gateway_host.go
@@ -8,9 +8,15 @@ import (
"strings"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/netbind"
)
func (h *Handler) effectiveLauncherPublic() bool {
+ if h.serverHostExplicit {
+ // -host takes precedence over -public and launcher-config public setting.
+ return false
+ }
+
if h.serverPublicExplicit {
return h.serverPublic
}
@@ -24,8 +30,11 @@ func (h *Handler) effectiveLauncherPublic() bool {
}
func (h *Handler) gatewayHostOverride() string {
+ if h.serverHostExplicit {
+ return strings.TrimSpace(h.serverHostInput)
+ }
if h.effectiveLauncherPublic() {
- return "0.0.0.0"
+ return "*"
}
return ""
}
@@ -41,10 +50,11 @@ func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string {
}
func gatewayProbeHost(bindHost string) string {
- if bindHost == "" || bindHost == "0.0.0.0" {
- return "127.0.0.1"
+ plan, err := netbind.BuildPlan(bindHost, netbind.DefaultLoopback)
+ if err != nil || strings.TrimSpace(plan.ProbeHost) == "" {
+ return netbind.ResolveAdaptiveLoopbackHost()
}
- return bindHost
+ return plan.ProbeHost
}
func (h *Handler) gatewayProxyURL() *url.URL {
@@ -72,11 +82,25 @@ func requestHostName(r *http.Request) string {
if strings.TrimSpace(r.Host) != "" {
return r.Host
}
- return "127.0.0.1"
+ return netbind.ResolveAdaptiveLoopbackHost()
+}
+
+func forwardedProtoFirst(r *http.Request) string {
+ raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
+ if raw == "" {
+ raw = forwardedRFC7239Proto(r)
+ }
+ if raw == "" {
+ return ""
+ }
+ if i := strings.IndexByte(raw, ','); i >= 0 {
+ raw = strings.TrimSpace(raw[:i])
+ }
+ return strings.ToLower(raw)
}
func requestWSScheme(r *http.Request) string {
- if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
+ if forwarded := forwardedProtoFirst(r); forwarded != "" {
proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0]))
if proto == "https" || proto == "wss" {
return "wss"
@@ -95,7 +119,7 @@ func requestWSScheme(r *http.Request) string {
// requestHTTPScheme returns http or https for URLs that are not WebSockets (e.g. SSE).
func requestHTTPScheme(r *http.Request) string {
- if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
+ if forwarded := forwardedProtoFirst(r); forwarded != "" {
proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0]))
if proto == "https" || proto == "wss" {
return "https"
@@ -107,6 +131,7 @@ func requestHTTPScheme(r *http.Request) string {
if r.TLS != nil {
return "https"
}
+
return "http"
}
@@ -128,6 +153,14 @@ func forwardedHostFirst(r *http.Request) string {
// forwardedRFC7239Host parses host= from the first Forwarded header element (RFC 7239).
func forwardedRFC7239Host(r *http.Request) string {
+ return forwardedRFC7239Param(r, "host")
+}
+
+func forwardedRFC7239Proto(r *http.Request) string {
+ return forwardedRFC7239Param(r, "proto")
+}
+
+func forwardedRFC7239Param(r *http.Request, key string) string {
v := strings.TrimSpace(r.Header.Get("Forwarded"))
if v == "" {
return ""
@@ -136,7 +169,7 @@ func forwardedRFC7239Host(r *http.Request) string {
for _, part := range strings.Split(first, ";") {
part = strings.TrimSpace(part)
low := strings.ToLower(part)
- if !strings.HasPrefix(low, "host=") {
+ if !strings.HasPrefix(low, key+"=") {
continue
}
val := strings.TrimSpace(part[strings.IndexByte(part, '=')+1:])
@@ -167,13 +200,21 @@ func clientVisiblePort(r *http.Request, serverListenPort int) string {
if p := forwardedPortFirst(r); p != "" {
return p
}
+ if fwdHost := forwardedHostFirst(r); fwdHost != "" {
+ if _, port, err := net.SplitHostPort(fwdHost); err == nil && port != "" {
+ return port
+ }
+ }
if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" {
return port
}
+ if strings.TrimSpace(r.Host) == "" && forwardedHostFirst(r) == "" {
+ return strconv.Itoa(serverListenPort)
+ }
if requestHTTPScheme(r) == "https" {
return "443"
}
- return strconv.Itoa(serverListenPort)
+ return "80"
}
// joinClientVisibleHostPort builds host:port for absolute URLs returned to the browser.
@@ -190,13 +231,12 @@ func joinClientVisibleHostPort(r *http.Request, host string, serverListenPort in
func (h *Handler) picoWebUIAddr(r *http.Request) string {
wsPort := h.serverPort
if wsPort == 0 {
- wsPort = 18800 // default web server port
+ wsPort = 18800
}
if fwdHost := forwardedHostFirst(r); fwdHost != "" {
return joinClientVisibleHostPort(r, fwdHost, wsPort)
}
- host := requestHostName(r)
- return net.JoinHostPort(host, strconv.Itoa(wsPort))
+ return joinClientVisibleHostPort(r, requestHostName(r), wsPort)
}
func (h *Handler) buildWsURL(r *http.Request) string {
diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go
index 7150b6fee..54d1010d2 100644
--- a/web/backend/api/gateway_host_test.go
+++ b/web/backend/api/gateway_host_test.go
@@ -3,6 +3,7 @@ package api
import (
"crypto/tls"
"errors"
+ "net"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -10,6 +11,7 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/netbind"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
)
@@ -26,8 +28,8 @@ func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) {
h := NewHandler(configPath)
h.SetServerOptions(18800, true, true, nil)
- if got := h.gatewayHostOverride(); got != "0.0.0.0" {
- t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0")
+ if got := h.gatewayHostOverride(); got != "*" {
+ t.Fatalf("gatewayHostOverride() = %q, want %q", got, "*")
}
}
@@ -48,7 +50,7 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) {
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = 18790
- req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
+ req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil)
req.Host = "192.168.1.9:18800"
if got := h.buildWsURL(req); got != "ws://192.168.1.9:18800/pico/ws" {
@@ -64,8 +66,36 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) {
}
func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) {
- if got := gatewayProbeHost("0.0.0.0"); got != "127.0.0.1" {
- t.Fatalf("gatewayProbeHost() = %q, want %q", got, "127.0.0.1")
+ want := "127.0.0.1"
+ if got := gatewayProbeHost("0.0.0.0"); got != want {
+ t.Fatalf("gatewayProbeHost() = %q, want %q", got, want)
+ }
+}
+
+func TestGatewayProbeHostUsesPreferredLoopbackForEmptyBind(t *testing.T) {
+ want := netbind.ResolveAdaptiveLoopbackHost()
+ if got := gatewayProbeHost(""); got != want {
+ t.Fatalf("gatewayProbeHost(empty) = %q, want %q", got, want)
+ }
+}
+
+func TestGatewayProbeHostUsesPreferredLoopbackForLocalhostBind(t *testing.T) {
+ want := netbind.ResolveAdaptiveLoopbackHost()
+ if got := gatewayProbeHost("localhost"); got != want {
+ t.Fatalf("gatewayProbeHost(localhost) = %q, want %q", got, want)
+ }
+}
+
+func TestGatewayProbeHostUsesLoopbackForIPv6WildcardBind(t *testing.T) {
+ want := "::1"
+ if got := gatewayProbeHost("::"); got != want {
+ t.Fatalf("gatewayProbeHost(::) = %q, want %q", got, want)
+ }
+}
+
+func TestGatewayProbeHostUsesFirstConcreteHostForMultiHostBind(t *testing.T) {
+ if got := gatewayProbeHost("127.0.0.1,::1"); got != "127.0.0.1" {
+ t.Fatalf("gatewayProbeHost(multi) = %q, want %q", got, "127.0.0.1")
}
}
@@ -137,8 +167,9 @@ func TestGetGatewayHealthUsesProbeHostForPublicLauncher(t *testing.T) {
_ = statusCode
_ = err
- if requestedURL != "http://127.0.0.1:18791/health" {
- t.Fatalf("health url = %q, want %q", requestedURL, "http://127.0.0.1:18791/health")
+ want := "http://" + net.JoinHostPort(netbind.ResolveAdaptiveLoopbackHost(), "18791") + "/health"
+ if requestedURL != want {
+ t.Fatalf("health url = %q, want %q", requestedURL, want)
}
}
@@ -150,12 +181,12 @@ func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) {
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Port = 18790
- req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
+ req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil)
req.Host = "chat.example.com"
req.Header.Set("X-Forwarded-Proto", "https")
- if got := h.buildWsURL(req); got != "wss://chat.example.com:18800/pico/ws" {
- t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18800/pico/ws")
+ if got := h.buildWsURL(req); got != "wss://chat.example.com:443/pico/ws" {
+ t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:443/pico/ws")
}
}
@@ -167,12 +198,12 @@ func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) {
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Port = 18790
- req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil)
+ req := httptest.NewRequest("GET", "https://launcher.local/api/pico/info", nil)
req.Host = "secure.example.com"
req.TLS = &tls.ConnectionState{}
- if got := h.buildWsURL(req); got != "wss://secure.example.com:18800/pico/ws" {
- t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18800/pico/ws")
+ if got := h.buildWsURL(req); got != "wss://secure.example.com:443/pico/ws" {
+ t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:443/pico/ws")
}
}
@@ -193,7 +224,7 @@ func TestBuildPicoURLsPreferXForwardedHost(t *testing.T) {
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Port = 18790
- req := httptest.NewRequest("GET", "http://127.0.0.1:18800/api/pico/token", nil)
+ req := httptest.NewRequest("GET", "http://127.0.0.1:18800/api/pico/info", nil)
req.Host = "127.0.0.1:18800"
req.Header.Set("X-Forwarded-Host", "vscode-tunnel.example.com")
req.Header.Set("X-Forwarded-Proto", "https")
@@ -218,13 +249,30 @@ func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) {
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Port = 18790
- req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil)
+ req := httptest.NewRequest("GET", "https://launcher.local/api/pico/info", nil)
req.Host = "chat.example.com"
req.TLS = &tls.ConnectionState{}
req.Header.Set("X-Forwarded-Proto", "http")
- if got := h.buildWsURL(req); got != "ws://chat.example.com:18800/pico/ws" {
- t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws")
+ if got := h.buildWsURL(req); got != "ws://chat.example.com:80/pico/ws" {
+ t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:80/pico/ws")
+ }
+}
+
+func TestBuildWsURLDoesNotTrustOriginWhenProxyOmitsForwardedProto(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+
+ req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil)
+ req.Host = "fs-952210-xwj.picoclaw.lan.sipeed.com"
+ req.Header.Set("Origin", "https://fs-952210-xwj.picoclaw.lan.sipeed.com")
+
+ if got := h.buildWsURL(req); got != "ws://fs-952210-xwj.picoclaw.lan.sipeed.com:80/pico/ws" {
+ t.Fatalf(
+ "buildWsURL() = %q, want %q",
+ got,
+ "ws://fs-952210-xwj.picoclaw.lan.sipeed.com:80/pico/ws",
+ )
}
}
@@ -233,10 +281,50 @@ func TestBuildWsURLUsesRequestHostNotGatewayBindLoopback(t *testing.T) {
h := NewHandler(configPath)
h.SetServerOptions(18800, false, false, nil)
- req := httptest.NewRequest("GET", "http://localhost:18800/api/pico/token", nil)
+ req := httptest.NewRequest("GET", "http://localhost:18800/api/pico/info", nil)
req.Host = "localhost:18800"
if got := h.buildWsURL(req); got != "ws://localhost:18800/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://localhost:18800/pico/ws")
}
}
+
+func TestGatewayHostOverrideWithExplicitHostAndAlignedGatewayHost(t *testing.T) {
+ h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
+ h.SetServerOptions(18800, false, false, nil)
+ h.SetServerBindHost("0.0.0.0", true)
+
+ if got := h.gatewayHostOverride(); got != "0.0.0.0" {
+ t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0")
+ }
+}
+
+func TestGatewayHostOverrideWithExplicitHostAndLocalhostGatewayHost(t *testing.T) {
+ h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
+ h.SetServerOptions(18800, false, false, nil)
+ h.SetServerBindHost("::", true)
+
+ if got := h.gatewayHostOverride(); got != "::" {
+ t.Fatalf("gatewayHostOverride() = %q, want %q", got, "::")
+ }
+}
+
+func TestGatewayHostOverrideWithExplicitMultiHost(t *testing.T) {
+ h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
+ h.SetServerOptions(18800, false, false, nil)
+ h.SetServerBindHost("127.0.0.1,::1", true)
+
+ if got := h.gatewayHostOverride(); got != "127.0.0.1,::1" {
+ t.Fatalf("gatewayHostOverride() = %q, want %q", got, "127.0.0.1,::1")
+ }
+}
+
+func TestGatewayHostExplicitIgnoresPublicFlag(t *testing.T) {
+ h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
+ h.SetServerOptions(18800, true, true, nil)
+ h.SetServerBindHost("127.0.0.1", true)
+
+ if got := h.effectiveLauncherPublic(); got {
+ t.Fatalf("effectiveLauncherPublic() = %t, want false when explicit host is set", got)
+ }
+}
diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go
index 42f0ab66c..f383089a6 100644
--- a/web/backend/api/gateway_test.go
+++ b/web/backend/api/gateway_test.go
@@ -17,6 +17,7 @@ import (
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
+ ppid "github.com/sipeed/picoclaw/pkg/pid"
"github.com/sipeed/picoclaw/web/backend/utils"
)
@@ -37,6 +38,36 @@ func startLongRunningProcess(t *testing.T) *exec.Cmd {
return cmd
}
+func startGatewayLikeProcess(t *testing.T) *exec.Cmd {
+ t.Helper()
+
+ var cmd *exec.Cmd
+ if runtime.GOOS == "windows" {
+ t.Skip("gateway-like process commandline check is not deterministic on Windows tests")
+ }
+ cmd = exec.Command("sh", "-c", "sleep 30 # picoclaw gateway")
+
+ if err := cmd.Start(); err != nil {
+ t.Fatalf("Start() error = %v", err)
+ }
+
+ return cmd
+}
+
+func writeTestPidFile(t *testing.T, data ppid.PidFileData) string {
+ t.Helper()
+
+ path := filepath.Join(globalConfigDir(), ".picoclaw.pid")
+ raw, err := json.MarshalIndent(data, "", " ")
+ if err != nil {
+ t.Fatalf("marshal pid file: %v", err)
+ }
+ if err := os.WriteFile(path, raw, 0o600); err != nil {
+ t.Fatalf("write pid file: %v", err)
+ }
+ return path
+}
+
func mockGatewayHealthResponse(statusCode, pid int) *http.Response {
return &http.Response{
StatusCode: statusCode,
@@ -65,17 +96,24 @@ func resetGatewayTestState(t *testing.T) {
t.Helper()
originalHealthGet := gatewayHealthGet
+ originalProcessMatcher := gatewayProcessMatcher
+ originalExecCommand := gatewayExecCommand
originalRestartGracePeriod := gatewayRestartGracePeriod
originalRestartForceKillWindow := gatewayRestartForceKillWindow
originalRestartPollInterval := gatewayRestartPollInterval
+ t.Setenv("PICOCLAW_HOME", t.TempDir())
t.Cleanup(func() {
gatewayHealthGet = originalHealthGet
+ gatewayProcessMatcher = originalProcessMatcher
+ gatewayExecCommand = originalExecCommand
gatewayRestartGracePeriod = originalRestartGracePeriod
gatewayRestartForceKillWindow = originalRestartForceKillWindow
gatewayRestartPollInterval = originalRestartPollInterval
gateway.mu.Lock()
gateway.cmd = nil
+ gateway.pidData = nil
+ gateway.owned = false
gateway.bootDefaultModel = ""
gateway.bootConfigSignature = ""
setGatewayRuntimeStatusLocked("stopped")
@@ -83,6 +121,226 @@ func resetGatewayTestState(t *testing.T) {
})
}
+func TestPicoGatewayProtocol(t *testing.T) {
+ resetGatewayTestState(t)
+
+ gateway.mu.Lock()
+ gateway.picoToken = "ui-token"
+ gateway.mu.Unlock()
+
+ if got := picoGatewayProtocol(); got != tokenPrefix+"ui-token" {
+ t.Fatalf("picoGatewayProtocol() = %q, want %q", got, tokenPrefix+"ui-token")
+ }
+}
+
+type gatewayStartEnvSnapshot struct {
+ GatewayHost string `json:"gateway_host"`
+ GatewayHostSet bool `json:"gateway_host_set"`
+ ConfigPath string `json:"config_path"`
+}
+
+func TestGatewayStartHelperProcess(t *testing.T) {
+ var envPath string
+ for i, arg := range os.Args {
+ if arg == "--" && i+2 < len(os.Args) && os.Args[i+1] == "gateway-env-helper" {
+ envPath = os.Args[i+2]
+ break
+ }
+ }
+ if envPath == "" {
+ t.Skip("helper process")
+ }
+
+ host, ok := os.LookupEnv(config.EnvGatewayHost)
+ raw, err := json.Marshal(gatewayStartEnvSnapshot{
+ GatewayHost: host,
+ GatewayHostSet: ok,
+ ConfigPath: os.Getenv(config.EnvConfig),
+ })
+ if err != nil {
+ _, _ = io.WriteString(os.Stderr, err.Error())
+ os.Exit(2)
+ }
+ if err := os.WriteFile(envPath, raw, 0o600); err != nil {
+ _, _ = io.WriteString(os.Stderr, err.Error())
+ os.Exit(2)
+ }
+ os.Exit(0)
+}
+
+func unsetGatewayStartEnvForTest(t *testing.T, key string) {
+ t.Helper()
+
+ prev, hadPrev := os.LookupEnv(key)
+ if err := os.Unsetenv(key); err != nil {
+ t.Fatalf("Unsetenv(%q) error = %v", key, err)
+ }
+ t.Cleanup(func() {
+ if hadPrev {
+ _ = os.Setenv(key, prev)
+ return
+ }
+ _ = os.Unsetenv(key)
+ })
+}
+
+func newGatewayStartTestHandler(t *testing.T) *Handler {
+ t.Helper()
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ cfg := config.DefaultConfig()
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ h.SetServerOptions(18800, false, false, nil)
+ return h
+}
+
+func startGatewayAndCaptureEnv(t *testing.T, h *Handler) gatewayStartEnvSnapshot {
+ t.Helper()
+
+ unsetGatewayStartEnvForTest(t, config.EnvGatewayHost)
+
+ envPath := filepath.Join(t.TempDir(), "gateway-child-env.json")
+ gatewayExecCommand = func(_ string, _ ...string) *exec.Cmd {
+ return exec.Command(
+ os.Args[0],
+ "-test.run=TestGatewayStartHelperProcess",
+ "--",
+ "gateway-env-helper",
+ envPath,
+ )
+ }
+
+ pid, err := h.startGatewayLocked("starting", 0)
+ if err != nil {
+ t.Fatalf("startGatewayLocked() error = %v", err)
+ }
+ if pid <= 0 {
+ t.Fatalf("startGatewayLocked() pid = %d, want > 0", pid)
+ }
+
+ deadline := time.Now().Add(3 * time.Second)
+ for {
+ raw, err := os.ReadFile(envPath)
+ if err == nil {
+ var snapshot gatewayStartEnvSnapshot
+ err = json.Unmarshal(raw, &snapshot)
+ if err != nil {
+ t.Fatalf("Unmarshal(child env) error = %v", err)
+ }
+ return snapshot
+ }
+ if !os.IsNotExist(err) {
+ t.Fatalf("ReadFile(%q) error = %v", envPath, err)
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("timed out waiting for gateway child env snapshot %q", envPath)
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+}
+
+func TestStartGatewayLocked_ForwardsLauncherHostOverrideToGatewayEnv(t *testing.T) {
+ h := newGatewayStartTestHandler(t)
+ h.SetServerBindHost("127.0.0.1,::1", true)
+
+ snapshot := startGatewayAndCaptureEnv(t, h)
+ if !snapshot.GatewayHostSet {
+ t.Fatal("gateway host env was not set")
+ }
+ if snapshot.GatewayHost != "127.0.0.1,::1" {
+ t.Fatalf("gateway host env = %q, want %q", snapshot.GatewayHost, "127.0.0.1,::1")
+ }
+ if snapshot.ConfigPath != h.configPath {
+ t.Fatalf("config env = %q, want %q", snapshot.ConfigPath, h.configPath)
+ }
+}
+
+func TestStartGatewayLocked_ForwardsLauncherHostFromEnvironmentToGatewayEnv(t *testing.T) {
+ h := newGatewayStartTestHandler(t)
+ h.SetServerBindHost("::", true)
+
+ snapshot := startGatewayAndCaptureEnv(t, h)
+ if !snapshot.GatewayHostSet {
+ t.Fatal("gateway host env was not set")
+ }
+ if snapshot.GatewayHost != "::" {
+ t.Fatalf("gateway host env = %q, want %q", snapshot.GatewayHost, "::")
+ }
+}
+
+func TestStartGatewayLocked_ForwardsWildcardHostForPublicLauncher(t *testing.T) {
+ h := newGatewayStartTestHandler(t)
+ h.SetServerOptions(18800, true, true, nil)
+
+ snapshot := startGatewayAndCaptureEnv(t, h)
+ if !snapshot.GatewayHostSet {
+ t.Fatal("gateway host env was not set")
+ }
+ if snapshot.GatewayHost != "*" {
+ t.Fatalf("gateway host env = %q, want %q", snapshot.GatewayHost, "*")
+ }
+}
+
+func TestStartGatewayLocked_UsesReloadedConfigForBootSignature(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("sleep command differs on Windows")
+ }
+
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ cfg := config.DefaultConfig()
+ delete(cfg.Channels, "pico")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ h.SetServerOptions(18800, false, false, nil)
+ gatewayExecCommand = func(_ string, _ ...string) *exec.Cmd {
+ return exec.Command("sleep", "30")
+ }
+
+ originalSignature := computeConfigSignature(cfg)
+ pid, err := h.startGatewayLocked("starting", 0)
+ if err != nil {
+ t.Fatalf("startGatewayLocked() error = %v", err)
+ }
+ if pid <= 0 {
+ t.Fatalf("startGatewayLocked() pid = %d, want > 0", pid)
+ }
+
+ gateway.mu.Lock()
+ cmd := gateway.cmd
+ bootSignature := gateway.bootConfigSignature
+ gateway.mu.Unlock()
+ t.Cleanup(func() {
+ if cmd != nil && cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ if cmd != nil {
+ _ = cmd.Wait()
+ }
+ })
+
+ updatedCfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ expectedSignature := computeConfigSignature(updatedCfg)
+ if expectedSignature == originalSignature {
+ t.Fatal("expected EnsurePicoChannel() to change the config signature during gateway start")
+ }
+ if bootSignature != expectedSignature {
+ t.Fatalf("bootConfigSignature = %q, want %q", bootSignature, expectedSignature)
+ }
+}
+
func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
@@ -99,6 +357,143 @@ func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
}
}
+func TestGatewayStartReady_RejectsASROnlyDefaultModel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "elevenlabs-asr",
+ Provider: "elevenlabs",
+ Model: "scribe_v1",
+ APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
+ }}
+ cfg.Agents.Defaults.ModelName = "elevenlabs-asr"
+
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ ready, reason, err := h.gatewayStartReady()
+ if err != nil {
+ t.Fatalf("gatewayStartReady() error = %v", err)
+ }
+ if ready {
+ t.Fatal("gatewayStartReady() ready = true, want false")
+ }
+ if reason != `default model "elevenlabs-asr" is not usable for chat` {
+ t.Fatalf(
+ "gatewayStartReady() reason = %q, want %q",
+ reason,
+ `default model "elevenlabs-asr" is not usable for chat`,
+ )
+ }
+}
+
+func TestLooksLikeGatewayCommandLine(t *testing.T) {
+ cases := []struct {
+ name string
+ cmdline string
+ want bool
+ }{
+ {
+ name: "default picoclaw gateway",
+ cmdline: "/usr/local/bin/picoclaw gateway -E",
+ want: true,
+ },
+ {
+ name: "renamed binary with gateway subcommand",
+ cmdline: "/opt/bin/custom-claw gateway -E -d",
+ want: true,
+ },
+ {
+ name: "standalone gateway binary path",
+ cmdline: "/opt/bin/gateway -E",
+ want: true,
+ },
+ {
+ name: "non gateway process",
+ cmdline: "/bin/sleep 30",
+ want: false,
+ },
+ {
+ name: "gateway substring only",
+ cmdline: "/opt/bin/gatewayd --serve",
+ want: false,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := looksLikeGatewayCommandLine(tc.cmdline)
+ if got != tc.want {
+ t.Fatalf("looksLikeGatewayCommandLine(%q) = %v, want %v", tc.cmdline, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestValidateGatewayPidDataAcceptsHealthWhenMatcherInconclusive(t *testing.T) {
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+
+ const testPID = 34567
+ pidData := &ppid.PidFileData{
+ PID: testPID,
+ Host: "127.0.0.1",
+ Port: 18790,
+ }
+
+ gatewayProcessMatcher = func(int) (bool, bool) { return false, false }
+ gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
+ return mockGatewayHealthResponse(http.StatusOK, testPID), nil
+ }
+
+ ok, decisive, reason := h.validateGatewayPidData(pidData, nil)
+ if !ok {
+ t.Fatalf("validateGatewayPidData() ok = false, want true (reason=%q)", reason)
+ }
+ if !decisive {
+ t.Fatalf("validateGatewayPidData() decisive = false, want true")
+ }
+}
+
+func TestValidateGatewayPidDataRejectsHealthPidMismatchWhenMatcherInconclusive(t *testing.T) {
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+
+ pidData := &ppid.PidFileData{
+ PID: 34567,
+ Host: "127.0.0.1",
+ Port: 18790,
+ }
+
+ gatewayProcessMatcher = func(int) (bool, bool) { return false, false }
+ gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
+ return mockGatewayHealthResponse(http.StatusOK, 99999), nil
+ }
+
+ ok, decisive, reason := h.validateGatewayPidData(pidData, nil)
+ if ok {
+ t.Fatalf("validateGatewayPidData() ok = true, want false")
+ }
+ if !decisive {
+ t.Fatalf("validateGatewayPidData() decisive = false, want true")
+ }
+ if !strings.Contains(reason, "health pid mismatch") {
+ t.Fatalf("validateGatewayPidData() reason = %q, want contains %q", reason, "health pid mismatch")
+ }
+}
+
func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
@@ -165,6 +560,17 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
}
}
+func TestGatewayCommandArgsIncludesDebugFlagWhenEnabled(t *testing.T) {
+ h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
+ h.SetDebug(true)
+
+ args := h.gatewayCommandArgs()
+ want := []string{"gateway", "-E", "-d"}
+ if strings.Join(args, " ") != strings.Join(want, " ") {
+ t.Fatalf("gatewayCommandArgs() = %v, want %v", args, want)
+ }
+}
+
func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -430,7 +836,7 @@ func TestGatewayStatusKeepsRunningWhenHealthProbeFailsAfterRunning(t *testing.T)
}
}
-func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) {
+func TestGatewayStatusKeepsPidDataWhileTrackedProcessAliveWhenPidFileUnavailable(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
@@ -446,6 +852,173 @@ func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) {
_ = cmd.Wait()
})
+ gateway.mu.Lock()
+ gateway.cmd = cmd
+ gateway.pidData = &ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "existing-token",
+ }
+ setGatewayRuntimeStatusLocked("running")
+ gateway.mu.Unlock()
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ gateway.mu.Lock()
+ defer gateway.mu.Unlock()
+ if gateway.pidData == nil {
+ t.Fatal("gateway.pidData was cleared while runtime status remained running")
+ }
+}
+
+func TestGatewayStatusDowngradesRunningWhenTrackedProcessExitedAndPidFileMissing(t *testing.T) {
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ cmd := startLongRunningProcess(t)
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+
+ gateway.mu.Lock()
+ gateway.cmd = cmd
+ gateway.pidData = &ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "stale-token",
+ }
+ setGatewayRuntimeStatusLocked("running")
+ gateway.mu.Unlock()
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ var body map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+ if got := body["gateway_status"]; got != "stopped" {
+ t.Fatalf("gateway_status = %#v, want %q", got, "stopped")
+ }
+
+ gateway.mu.Lock()
+ defer gateway.mu.Unlock()
+ if gateway.pidData != nil {
+ t.Fatal("gateway.pidData should be cleared when tracked process has exited")
+ }
+}
+
+func TestGatewayStatusIgnoresAndRemovesPidFileForNonGatewayProcess(t *testing.T) {
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ cmd := startLongRunningProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+
+ pidPath := writeTestPidFile(t, ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "stale-token",
+ Host: "127.0.0.1",
+ Port: 18790,
+ })
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ var body map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+ if got := body["gateway_status"]; got != "stopped" {
+ t.Fatalf("gateway_status = %#v, want %q", got, "stopped")
+ }
+ if _, err := os.Stat(pidPath); !os.IsNotExist(err) {
+ t.Fatal("stale pid file should be removed for non-gateway process")
+ }
+}
+
+func TestGatewayStopRefusesNonGatewayAttachedProcess(t *testing.T) {
+ resetGatewayTestState(t)
+ if runtime.GOOS == "windows" {
+ t.Skip("commandline-based process type check is best-effort on Windows")
+ }
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ cmd := startLongRunningProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+
+ gateway.mu.Lock()
+ gateway.cmd = cmd
+ gateway.owned = false
+ setGatewayRuntimeStatusLocked("running")
+ gateway.mu.Unlock()
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/gateway/stop", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
+ }
+ if !isCmdProcessAliveLocked(cmd) {
+ t.Fatal("non-gateway process should not be terminated by /api/gateway/stop")
+ }
+}
+
+func TestGatewayStatusReportsRunningFromPidProbe(t *testing.T) {
+ resetGatewayTestState(t)
+ gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+
gateway.mu.Lock()
setGatewayRuntimeStatusLocked("stopped")
gateway.mu.Unlock()
@@ -454,6 +1027,13 @@ func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) {
return mockGatewayHealthResponse(http.StatusOK, cmd.Process.Pid), nil
}
+ writeTestPidFile(t, ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "test-token",
+ Host: "127.0.0.1",
+ Port: 18790,
+ })
+
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
@@ -470,9 +1050,6 @@ func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) {
if got := body["gateway_status"]; got != "running" {
t.Fatalf("gateway_status = %#v, want %q", got, "running")
}
- if got := body["pid"]; got != float64(cmd.Process.Pid) {
- t.Fatalf("pid = %#v, want %d", got, cmd.Process.Pid)
- }
if got := body["gateway_restart_required"]; got != false {
t.Fatalf("gateway_restart_required = %#v, want false", got)
}
@@ -480,6 +1057,7 @@ func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) {
func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
resetGatewayTestState(t)
+ gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
@@ -498,14 +1076,23 @@ func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
mux := http.NewServeMux()
h.RegisterRoutes(mux)
- process, err := os.FindProcess(os.Getpid())
- if err != nil {
- t.Fatalf("FindProcess() error = %v", err)
- }
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+ writeTestPidFile(t, ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "test-token",
+ Host: "127.0.0.1",
+ Port: 18790,
+ })
bootSignature := computeConfigSignature(cfg)
gateway.mu.Lock()
- gateway.cmd = &exec.Cmd{Process: process}
+ gateway.cmd = cmd
gateway.bootDefaultModel = cfg.ModelList[0].ModelName
gateway.bootConfigSignature = bootSignature
setGatewayRuntimeStatusLocked("running")
@@ -614,6 +1201,136 @@ func TestGatewayStatusRequiresRestartAfterToolChange(t *testing.T) {
}
}
+func TestGatewayStatusRequiresRestartAfterChannelChange(t *testing.T) {
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ cfg := config.DefaultConfig()
+ cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
+ cfg.ModelList[0].SetAPIKey("test-key")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ process, err := os.FindProcess(os.Getpid())
+ if err != nil {
+ t.Fatalf("FindProcess() error = %v", err)
+ }
+
+ bootSignature := computeConfigSignature(cfg)
+ gateway.mu.Lock()
+ gateway.cmd = &exec.Cmd{Process: process}
+ gateway.bootDefaultModel = cfg.ModelList[0].ModelName
+ gateway.bootConfigSignature = bootSignature
+ setGatewayRuntimeStatusLocked("running")
+ gateway.mu.Unlock()
+
+ updatedCfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ telegram := updatedCfg.Channels.Get("telegram")
+ if telegram == nil {
+ t.Fatalf("expected default telegram channel config")
+ }
+ telegram.Enabled = !telegram.Enabled
+ if err := config.SaveConfig(configPath, updatedCfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
+ return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil
+ }
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ var body map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+
+ if got := body["gateway_status"]; got != "running" {
+ t.Fatalf("gateway_status = %#v, want %q", got, "running")
+ }
+ if got := body["gateway_restart_required"]; got != true {
+ t.Fatalf("gateway_restart_required = %#v, want true", got)
+ }
+}
+
+func TestGatewayStatusRequiresRestartAfterWebSearchConfigChange(t *testing.T) {
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ cfg := config.DefaultConfig()
+ cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
+ cfg.ModelList[0].SetAPIKey("test-key")
+ cfg.Tools.Web.Enabled = true
+ cfg.Tools.Web.Provider = "sogou"
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ process, err := os.FindProcess(os.Getpid())
+ if err != nil {
+ t.Fatalf("FindProcess() error = %v", err)
+ }
+
+ bootSignature := computeConfigSignature(cfg)
+ gateway.mu.Lock()
+ gateway.cmd = &exec.Cmd{Process: process}
+ gateway.bootDefaultModel = cfg.ModelList[0].ModelName
+ gateway.bootConfigSignature = bootSignature
+ setGatewayRuntimeStatusLocked("running")
+ gateway.mu.Unlock()
+
+ updatedCfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ updatedCfg.Tools.Web.Provider = "duckduckgo"
+ if err := config.SaveConfig(configPath, updatedCfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
+ return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil
+ }
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ var body map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+
+ if got := body["gateway_status"]; got != "running" {
+ t.Fatalf("gateway_status = %#v, want %q", got, "running")
+ }
+ if got := body["gateway_restart_required"]; got != true {
+ t.Fatalf("gateway_restart_required = %#v, want true", got)
+ }
+}
+
func TestGatewayStatusNoRestartRequiredForNonSensitiveChanges(t *testing.T) {
resetGatewayTestState(t)
diff --git a/web/backend/api/launcher_config.go b/web/backend/api/launcher_config.go
index e149d5671..92911157c 100644
--- a/web/backend/api/launcher_config.go
+++ b/web/backend/api/launcher_config.go
@@ -61,11 +61,15 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
return
}
- cfg := launcherconfig.Config{
- Port: payload.Port,
- Public: payload.Public,
- AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...),
+ cfg, err := h.loadLauncherConfig()
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError)
+ return
}
+ cfg.Port = payload.Port
+ cfg.Public = payload.Public
+ cfg.AllowedCIDRs = append([]string(nil), payload.AllowedCIDRs...)
+ cfg.LegacyLauncherToken = ""
if err := launcherconfig.Validate(cfg); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
diff --git a/web/backend/api/launcher_config_test.go b/web/backend/api/launcher_config_test.go
index 0d6af823c..68ab1be42 100644
--- a/web/backend/api/launcher_config_test.go
+++ b/web/backend/api/launcher_config_test.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "os"
"path/filepath"
"strings"
"testing"
@@ -41,6 +42,14 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
func TestPutLauncherConfigPersists(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
+ path := launcherconfig.PathForAppConfig(configPath)
+ if err := os.WriteFile(
+ path,
+ []byte(`{"port":18800,"public":false,"dashboard_password_hash":"saved-hash","launcher_token":"legacy-token"}`),
+ 0o600,
+ ); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
h := NewHandler(configPath)
mux := http.NewServeMux()
@@ -50,7 +59,9 @@ func TestPutLauncherConfigPersists(t *testing.T) {
req := httptest.NewRequest(
http.MethodPut,
"/api/system/launcher-config",
- strings.NewReader(`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`),
+ strings.NewReader(
+ `{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`,
+ ),
)
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
@@ -59,7 +70,6 @@ func TestPutLauncherConfigPersists(t *testing.T) {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
- path := launcherconfig.PathForAppConfig(configPath)
cfg, err := launcherconfig.Load(path, launcherconfig.Default())
if err != nil {
t.Fatalf("launcherconfig.Load() error = %v", err)
@@ -67,6 +77,12 @@ func TestPutLauncherConfigPersists(t *testing.T) {
if cfg.Port != 18080 || !cfg.Public {
t.Fatalf("saved config = %+v, want port=18080 public=true", cfg)
}
+ if cfg.DashboardPasswordHash != "saved-hash" {
+ t.Fatalf("saved dashboard_password_hash = %q, want saved-hash", cfg.DashboardPasswordHash)
+ }
+ if cfg.LegacyLauncherToken != "" {
+ t.Fatalf("saved legacy launcher_token = %q, want empty", cfg.LegacyLauncherToken)
+ }
if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" {
t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs)
}
diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go
index aeef85119..302231d80 100644
--- a/web/backend/api/model_status.go
+++ b/web/backend/api/model_status.go
@@ -1,37 +1,107 @@
package api
import (
+ "context"
"encoding/json"
"fmt"
+ "hash/fnv"
"net"
"net/http"
"net/url"
+ "os/exec"
+ "strconv"
"strings"
+ "sync"
"time"
+ "golang.org/x/sync/singleflight"
+
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
)
-const modelProbeTimeout = 800 * time.Millisecond
+const (
+ modelProbeTimeout = 800 * time.Millisecond
+ modelProbeSuccessBaseInterval = 2 * time.Second
+ modelProbeSuccessMaxInterval = 60 * time.Second
+ modelProbeFailureBaseInterval = 1 * time.Second
+ modelProbeFailureMaxInterval = 30 * time.Second
+ modelProbeBackoffMaxShift = 8
+ modelProbeCacheMaxEntries = 1024
+ modelProbeCacheEntryTTL = 30 * time.Minute
+ modelProbeCacheTrimToEntries = modelProbeCacheMaxEntries * 8 / 10
+ modelProbeTTLGCInterval = 1 * time.Minute
+)
+
+const (
+ modelStatusAvailable = "available"
+ modelStatusUnconfigured = "unconfigured"
+ modelStatusUnreachable = "unreachable"
+)
+
+type modelConfigurationSummary struct {
+ Available bool
+ Status string
+}
var (
probeTCPServiceFunc = probeTCPService
probeOllamaModelFunc = probeOllamaModel
probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel
+ probeCommandAvailableFunc = probeCommandAvailable
+ modelProbeNowFunc = time.Now
+ modelProbeState = newModelProbeCacheState()
)
+type modelProbeCacheState struct {
+ mu sync.RWMutex
+ cache map[string]*modelProbeCacheEntry
+ group singleflight.Group
+ nextTTLGCAt time.Time
+}
+
+type modelProbeCacheEntry struct {
+ lastResult bool
+ hasResult bool
+ successStreak int
+ failureStreak int
+ nextProbeAt time.Time
+ updatedAt time.Time
+}
+
+func newModelProbeCacheState() *modelProbeCacheState {
+ return &modelProbeCacheState{cache: map[string]*modelProbeCacheEntry{}}
+}
+
+func resetModelProbeCache() {
+ modelProbeState.resetForTest()
+}
+
+func (s *modelProbeCacheState) resetForTest() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.cache = map[string]*modelProbeCacheEntry{}
+ s.nextTTLGCAt = time.Time{}
+}
+
func hasModelConfiguration(m *config.ModelConfig) bool {
+ protocol := modelProtocol(m)
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
apiKey := strings.TrimSpace(m.APIKey())
if authMethod == "oauth" || authMethod == "token" {
- if provider, ok := oauthProviderForModel(m.Model); ok {
- cred, err := oauthGetCredential(provider)
- if err != nil || cred == nil {
- return false
- }
- return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != ""
+ if configured, checked := hasStoredOAuthCredential(m); checked {
+ return configured
}
+ }
+
+ if authMethod == "" && providerUsesImplicitOAuth(protocol) {
+ if configured, checked := hasStoredOAuthCredential(m); checked {
+ return configured
+ }
+ }
+
+ if providerUsesAmbientCredentials(protocol) {
return true
}
@@ -42,16 +112,51 @@ func hasModelConfiguration(m *config.ModelConfig) bool {
return apiKey != ""
}
-// isModelConfigured reports whether a model is currently available to use.
-// Local models must be reachable; remote/API-key models only need saved config.
-func isModelConfigured(m *config.ModelConfig) bool {
- if !hasModelConfiguration(m) {
+func hasStoredOAuthCredential(m *config.ModelConfig) (bool, bool) {
+ provider, ok := oauthProviderForModel(m)
+ if !ok {
+ return false, false
+ }
+ cred, err := oauthGetCredential(provider)
+ if err != nil || cred == nil {
+ return false, true
+ }
+ return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != "", true
+}
+
+func providerUsesImplicitOAuth(protocol string) bool {
+ switch protocol {
+ case "antigravity", "google-antigravity":
+ return true
+ default:
return false
}
- if requiresRuntimeProbe(m) {
- return probeLocalModelAvailability(m)
+}
+
+func providerUsesAmbientCredentials(protocol string) bool {
+ switch protocol {
+ case "bedrock":
+ // Bedrock relies on the AWS SDK credential chain instead of an explicit
+ // API key stored in ModelConfig. We cannot reliably preflight every AWS
+ // credential source here, so avoid misclassifying valid environments as
+ // "unconfigured" and defer concrete credential failures to runtime.
+ return true
+ default:
+ return false
}
- return true
+}
+
+func modelConfigurationStatus(m *config.ModelConfig) modelConfigurationSummary {
+ if !hasModelConfiguration(m) {
+ return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured}
+ }
+ if requiresRuntimeProbe(m) {
+ if probeLocalModelAvailability(m) {
+ return modelConfigurationSummary{Available: true, Status: modelStatusAvailable}
+ }
+ return modelConfigurationSummary{Available: false, Status: modelStatusUnreachable}
+ }
+ return modelConfigurationSummary{Available: true, Status: modelStatusAvailable}
}
func requiresRuntimeProbe(m *config.ModelConfig) bool {
@@ -60,10 +165,14 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool {
return true
}
- switch modelProtocol(m.Model) {
+ protocol := modelProtocol(m)
+
+ switch protocol {
case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot":
return true
- case "ollama", "vllm":
+ }
+
+ if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
apiBase := strings.TrimSpace(m.APIBase)
return apiBase == "" || hasLocalAPIBase(apiBase)
}
@@ -76,17 +185,47 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool {
}
func probeLocalModelAvailability(m *config.ModelConfig) bool {
+ cacheKey := modelProbeCacheKey(m)
+ return modelProbeState.probe(cacheKey, func() bool {
+ return runLocalModelProbe(m)
+ })
+}
+
+func (s *modelProbeCacheState) probe(cacheKey string, probeFunc func() bool) bool {
+ now := modelProbeNowFunc()
+ if cachedResult, ok := s.getCachedResult(cacheKey, now); ok {
+ return cachedResult
+ }
+
+ v, _, _ := s.group.Do(cacheKey, func() (any, error) {
+ now = modelProbeNowFunc()
+ if cachedResult, ok := s.getCachedResult(cacheKey, now); ok {
+ return cachedResult, nil
+ }
+
+ result := probeFunc()
+ s.setCachedResult(cacheKey, result, now)
+ return result, nil
+ })
+
+ result, _ := v.(bool)
+ return result
+}
+
+func runLocalModelProbe(m *config.ModelConfig) bool {
apiBase := modelProbeAPIBase(m)
- protocol, modelID := splitModel(m.Model)
+ protocol, modelID := splitModel(m)
switch protocol {
case "ollama":
return probeOllamaModelFunc(apiBase, modelID)
- case "vllm":
+ case "vllm", "lmstudio":
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
case "github-copilot", "copilot":
return probeTCPServiceFunc(apiBase)
- case "claude-cli", "claudecli", "codex-cli", "codexcli":
- return true
+ case "claude-cli", "claudecli":
+ return probeCommandAvailableFunc("claude")
+ case "codex-cli", "codexcli":
+ return probeCommandAvailableFunc("codex")
default:
if hasLocalAPIBase(apiBase) {
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
@@ -95,16 +234,211 @@ func probeLocalModelAvailability(m *config.ModelConfig) bool {
}
}
+func probeCommandAvailable(command string) bool {
+ _, err := exec.LookPath(command)
+ return err == nil
+}
+
+func modelProbeCacheKey(m *config.ModelConfig) string {
+ protocol, modelID := splitModel(m)
+
+ apiBaseRaw := modelProbeAPIBase(m)
+ apiBase := strings.ToLower(strings.TrimRight(strings.TrimSpace(apiBaseRaw), "/"))
+ apiKeyFingerprint := modelProbeAPIKeyFingerprint(m.APIKey())
+
+ var b strings.Builder
+ b.Grow(len(protocol) + len(modelID) + len(apiBase) + len(apiKeyFingerprint) + 8)
+ b.WriteString(protocol)
+ b.WriteByte('|')
+ b.WriteString(modelID)
+ b.WriteByte('|')
+ b.WriteString(apiBase)
+ b.WriteByte('|')
+ b.WriteString(apiKeyFingerprint)
+
+ return b.String()
+}
+
+func modelProbeAPIKeyFingerprint(raw string) string {
+ apiKey := strings.TrimSpace(raw)
+ if apiKey == "" {
+ return "none"
+ }
+
+ h := fnv.New64a()
+ _, _ = h.Write([]byte(apiKey))
+ return strconv.FormatUint(h.Sum64(), 36)
+}
+
+func (s *modelProbeCacheState) getCachedResult(cacheKey string, now time.Time) (bool, bool) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ entry, ok := s.cache[cacheKey]
+ if !ok || !entry.hasResult {
+ return false, false
+ }
+ if now.Before(entry.nextProbeAt) {
+ return entry.lastResult, true
+ }
+ return false, false
+}
+
+func (s *modelProbeCacheState) setCachedResult(cacheKey string, result bool, now time.Time) {
+ s.mu.Lock()
+
+ entry, ok := s.cache[cacheKey]
+ if !ok {
+ entry = &modelProbeCacheEntry{}
+ s.cache[cacheKey] = entry
+ }
+
+ entry.lastResult = result
+ entry.hasResult = true
+ entry.updatedAt = now
+
+ var delay time.Duration
+ if result {
+ entry.successStreak++
+ entry.failureStreak = 0
+ delay = modelProbeBackoffDelay(
+ modelProbeSuccessBaseInterval,
+ modelProbeSuccessMaxInterval,
+ entry.successStreak,
+ )
+ } else {
+ entry.failureStreak++
+ entry.successStreak = 0
+ delay = modelProbeBackoffDelay(
+ modelProbeFailureBaseInterval,
+ modelProbeFailureMaxInterval,
+ entry.failureStreak,
+ )
+ }
+
+ entry.nextProbeAt = now.Add(delay)
+
+ shouldRunTTLGC := modelProbeCacheEntryTTL > 0 && (s.nextTTLGCAt.IsZero() || !now.Before(s.nextTTLGCAt))
+ if shouldRunTTLGC {
+ s.nextTTLGCAt = now.Add(modelProbeTTLGCInterval)
+ }
+ shouldRunSizeGC := len(s.cache) > modelProbeCacheMaxEntries
+ s.mu.Unlock()
+
+ if shouldRunTTLGC || shouldRunSizeGC {
+ s.gc(now, shouldRunTTLGC)
+ }
+}
+
+func (s *modelProbeCacheState) gc(now time.Time, runTTL bool) {
+ type evictionCandidate struct {
+ key string
+ updatedAt time.Time
+ }
+
+ var expireBefore time.Time
+ if runTTL && modelProbeCacheEntryTTL > 0 {
+ expireBefore = now.Add(-modelProbeCacheEntryTTL)
+ }
+
+ s.mu.RLock()
+ cacheLen := len(s.cache)
+ if cacheLen == 0 {
+ s.mu.RUnlock()
+ return
+ }
+
+ expiredKeys := make([]string, 0)
+ if !expireBefore.IsZero() {
+ expiredKeys = make([]string, 0, min(cacheLen/8+1, 64))
+ for key, entry := range s.cache {
+ if entry.updatedAt.Before(expireBefore) {
+ expiredKeys = append(expiredKeys, key)
+ }
+ }
+ }
+
+ effectiveLen := cacheLen - len(expiredKeys)
+ removeCount := max(effectiveLen-modelProbeCacheTrimToEntries, 0)
+
+ candidates := make([]evictionCandidate, 0)
+ if removeCount > 0 {
+ candidates = make([]evictionCandidate, 0, effectiveLen)
+ for key, entry := range s.cache {
+ if !expireBefore.IsZero() && entry.updatedAt.Before(expireBefore) {
+ continue
+ }
+ candidates = append(candidates, evictionCandidate{key: key, updatedAt: entry.updatedAt})
+ }
+ }
+ s.mu.RUnlock()
+
+ if len(expiredKeys) == 0 && len(candidates) == 0 {
+ return
+ }
+
+ toEvict := map[string]time.Time{}
+ for i := 0; i < removeCount && len(candidates) > 0; i++ {
+ oldest := 0
+ for j := 1; j < len(candidates); j++ {
+ if candidates[j].updatedAt.Before(candidates[oldest].updatedAt) {
+ oldest = j
+ }
+ }
+ victim := candidates[oldest]
+ toEvict[victim.key] = victim.updatedAt
+ candidates[oldest] = candidates[len(candidates)-1]
+ candidates = candidates[:len(candidates)-1]
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if !expireBefore.IsZero() {
+ for _, key := range expiredKeys {
+ entry, ok := s.cache[key]
+ if ok && entry.updatedAt.Before(expireBefore) {
+ delete(s.cache, key)
+ }
+ }
+ }
+
+ for key, victimUpdatedAt := range toEvict {
+ entry, ok := s.cache[key]
+ if ok && !entry.updatedAt.After(victimUpdatedAt) {
+ delete(s.cache, key)
+ }
+ }
+}
+
+func modelProbeBackoffDelay(base, maxDelay time.Duration, streak int) time.Duration {
+ if streak <= 0 {
+ streak = 1
+ }
+
+ shift := min(streak-1, modelProbeBackoffMaxShift)
+
+ delay := base * time.Duration(1< 0 && (delay > maxDelay || delay < 0) {
+ return maxDelay
+ }
+ if delay <= 0 {
+ return base
+ }
+ return delay
+}
+
func modelProbeAPIBase(m *config.ModelConfig) string {
if apiBase := strings.TrimSpace(m.APIBase); apiBase != "" {
return normalizeModelProbeAPIBase(apiBase)
}
- switch modelProtocol(m.Model) {
- case "ollama":
- return "http://localhost:11434/v1"
- case "vllm":
- return "http://localhost:8000/v1"
+ protocol := modelProtocol(m)
+ if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
+ return providers.DefaultAPIBaseForProtocol(protocol)
+ }
+
+ switch protocol {
case "github-copilot", "copilot":
return "localhost:4321"
default:
@@ -134,8 +468,8 @@ func normalizeModelProbeAPIBase(raw string) string {
return u.String()
}
-func oauthProviderForModel(model string) (string, bool) {
- switch modelProtocol(model) {
+func oauthProviderForModel(m *config.ModelConfig) (string, bool) {
+ switch modelProtocol(m) {
case "openai":
return oauthProviderOpenAI, true
case "anthropic":
@@ -147,18 +481,14 @@ func oauthProviderForModel(model string) (string, bool) {
}
}
-func modelProtocol(model string) string {
- protocol, _ := splitModel(model)
+func modelProtocol(m *config.ModelConfig) string {
+ protocol, _ := splitModel(m)
return protocol
}
-func splitModel(model string) (protocol, modelID string) {
- model = strings.ToLower(strings.TrimSpace(model))
- protocol, _, found := strings.Cut(model, "/")
- if !found {
- return "openai", model
- }
- return protocol, strings.TrimSpace(model[strings.Index(model, "/")+1:])
+func splitModel(m *config.ModelConfig) (protocol, modelID string) {
+ protocol, modelID = providers.ExtractProtocol(m)
+ return strings.ToLower(strings.TrimSpace(protocol)), strings.ToLower(strings.TrimSpace(modelID))
}
func hasLocalAPIBase(raw string) bool {
@@ -189,7 +519,11 @@ func probeTCPService(raw string) bool {
return false
}
- conn, err := net.DialTimeout("tcp", hostPort, modelProbeTimeout)
+ ctx, cancel := context.WithTimeout(context.Background(), modelProbeTimeout)
+ defer cancel()
+
+ dialer := &net.Dialer{}
+ conn, err := dialer.DialContext(ctx, "tcp", hostPort)
if err != nil {
return false
}
@@ -244,7 +578,10 @@ func probeOpenAICompatibleModel(apiBase, modelID, apiKey string) bool {
}
func getJSON(rawURL string, out any, apiKey string) error {
- req, err := http.NewRequest(http.MethodGet, rawURL, nil)
+ ctx, cancel := context.WithTimeout(context.Background(), modelProbeTimeout)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return err
}
@@ -252,7 +589,7 @@ func getJSON(rawURL string, out any, apiKey string) error {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
- client := &http.Client{Timeout: modelProbeTimeout}
+ client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
@@ -318,10 +655,29 @@ func ollamaModelMatches(candidate, want string) bool {
if candidate == "" || want == "" {
return false
}
- if strings.EqualFold(candidate, want) {
- return true
+
+ candidateBase, candidateTag := splitOllamaModel(candidate)
+ wantBase, wantTag := splitOllamaModel(want)
+ if candidateBase == "" || wantBase == "" {
+ return false
}
- base, _, _ := strings.Cut(candidate, ":")
- return strings.EqualFold(base, want)
+ if candidateTag == "" {
+ candidateTag = "latest"
+ }
+ if wantTag == "" {
+ wantTag = "latest"
+ }
+
+ return strings.EqualFold(candidateBase, wantBase) && strings.EqualFold(candidateTag, wantTag)
+}
+
+func splitOllamaModel(raw string) (base, tag string) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", ""
+ }
+
+ base, tag, _ = strings.Cut(raw, ":")
+ return strings.TrimSpace(base), strings.TrimSpace(tag)
}
diff --git a/web/backend/api/model_status_test.go b/web/backend/api/model_status_test.go
index df942a9e9..d5463a856 100644
--- a/web/backend/api/model_status_test.go
+++ b/web/backend/api/model_status_test.go
@@ -3,7 +3,10 @@ package api
import (
"net/http"
"net/http/httptest"
+ "sync"
+ "sync/atomic"
"testing"
+ "time"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -35,3 +38,357 @@ func TestProbeLocalModelAvailability_OpenAICompatibleIncludesAPIKey(t *testing.T
t.Fatal("probeLocalModelAvailability() = false, want true when api_key is configured")
}
}
+
+func TestRequiresRuntimeProbe_LMStudio(t *testing.T) {
+ if !requiresRuntimeProbe(&config.ModelConfig{
+ Model: "lmstudio/openai/gpt-oss-20b",
+ }) {
+ t.Fatal("requiresRuntimeProbe(lmstudio with default base) = false, want true")
+ }
+
+ if requiresRuntimeProbe(&config.ModelConfig{
+ Model: "lmstudio/openai/gpt-oss-20b",
+ APIBase: "https://api.example.com/v1",
+ }) {
+ t.Fatal("requiresRuntimeProbe(lmstudio with remote base) = true, want false")
+ }
+}
+
+func TestModelProbeAPIBase_LMStudioDefault(t *testing.T) {
+ got := modelProbeAPIBase(&config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"})
+ if got != "http://localhost:1234/v1" {
+ t.Fatalf("modelProbeAPIBase(lmstudio) = %q, want %q", got, "http://localhost:1234/v1")
+ }
+}
+
+func TestProbeLocalModelAvailability_LMStudioUsesOpenAICompatibleProbe(t *testing.T) {
+ originalProbe := probeOpenAICompatibleModelFunc
+ defer func() { probeOpenAICompatibleModelFunc = originalProbe }()
+
+ called := false
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ called = true
+ if apiBase != "http://localhost:1234/v1" {
+ t.Fatalf("apiBase = %q, want %q", apiBase, "http://localhost:1234/v1")
+ }
+ if modelID != "openai/gpt-oss-20b" {
+ t.Fatalf("modelID = %q, want %q", modelID, "openai/gpt-oss-20b")
+ }
+ if apiKey != "" {
+ t.Fatalf("apiKey = %q, want empty", apiKey)
+ }
+ return true
+ }
+
+ model := &config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"}
+ if !probeLocalModelAvailability(model) {
+ t.Fatal("probeLocalModelAvailability(lmstudio) = false, want true")
+ }
+ if !called {
+ t.Fatal("probeOpenAICompatibleModelFunc was not called for lmstudio")
+ }
+}
+
+func TestModelProbeCacheKey_DifferentAPIKeysProduceDifferentKeys(t *testing.T) {
+ base := &config.ModelConfig{
+ ModelName: "local-vllm",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ AuthMethod: "local",
+ ConnectMode: "",
+ }
+
+ m1 := *base
+ m1.SetAPIKey("key-a")
+ m2 := *base
+ m2.SetAPIKey("key-b")
+
+ k1 := modelProbeCacheKey(&m1)
+ k2 := modelProbeCacheKey(&m2)
+ if k1 == k2 {
+ t.Fatal("modelProbeCacheKey() should differ when api key changes")
+ }
+}
+
+func TestModelProbeCacheKey_NormalizesTrailingSlashInAPIBase(t *testing.T) {
+ m1 := &config.ModelConfig{
+ ModelName: "local-vllm",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ }
+ m2 := &config.ModelConfig{
+ ModelName: "local-vllm",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1/",
+ }
+
+ k1 := modelProbeCacheKey(m1)
+ k2 := modelProbeCacheKey(m2)
+ if k1 != k2 {
+ t.Fatalf("modelProbeCacheKey() mismatch for equivalent api_base values: %q vs %q", k1, k2)
+ }
+}
+
+func TestModelProbeCacheKey_IgnoresDisplayAndConnectionFields(t *testing.T) {
+ base := &config.ModelConfig{
+ ModelName: "vllm-one",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ AuthMethod: "none",
+ ConnectMode: "http",
+ }
+ changed := &config.ModelConfig{
+ ModelName: "vllm-two",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ AuthMethod: "token",
+ ConnectMode: "ws",
+ }
+
+ k1 := modelProbeCacheKey(base)
+ k2 := modelProbeCacheKey(changed)
+ if k1 != k2 {
+ t.Fatalf("modelProbeCacheKey() should ignore non-probe fields, got %q vs %q", k1, k2)
+ }
+}
+
+func TestProbeLocalModelAvailability_SuccessBackoff(t *testing.T) {
+ resetModelProbeHooks(t)
+
+ now := time.Unix(1700000000, 0)
+ modelProbeNowFunc = func() time.Time { return now }
+
+ calls := 0
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ calls++
+ return true
+ }
+
+ model := &config.ModelConfig{
+ ModelName: "local-vllm",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ }
+
+ if !probeLocalModelAvailability(model) {
+ t.Fatal("first probe result = false, want true")
+ }
+ if calls != 1 {
+ t.Fatalf("probe calls after first probe = %d, want 1", calls)
+ }
+
+ if !probeLocalModelAvailability(model) {
+ t.Fatal("cached probe result = false, want true")
+ }
+ if calls != 1 {
+ t.Fatalf("probe calls after immediate re-check = %d, want 1", calls)
+ }
+
+ now = now.Add(modelProbeSuccessBaseInterval)
+ if !probeLocalModelAvailability(model) {
+ t.Fatal("second probe result = false, want true")
+ }
+ if calls != 2 {
+ t.Fatalf("probe calls after success backoff window = %d, want 2", calls)
+ }
+
+ now = now.Add(modelProbeSuccessBaseInterval)
+ if !probeLocalModelAvailability(model) {
+ t.Fatal("cached result after doubled backoff = false, want true")
+ }
+ if calls != 2 {
+ t.Fatalf("probe calls before doubled backoff expires = %d, want 2", calls)
+ }
+
+ now = now.Add(modelProbeSuccessBaseInterval)
+ if !probeLocalModelAvailability(model) {
+ t.Fatal("third probe result = false, want true")
+ }
+ if calls != 3 {
+ t.Fatalf("probe calls after doubled backoff expires = %d, want 3", calls)
+ }
+}
+
+func TestProbeLocalModelAvailability_FailureBackoff(t *testing.T) {
+ resetModelProbeHooks(t)
+
+ now := time.Unix(1700000100, 0)
+ modelProbeNowFunc = func() time.Time { return now }
+
+ calls := 0
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ calls++
+ return false
+ }
+
+ model := &config.ModelConfig{
+ ModelName: "local-vllm",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ }
+
+ if probeLocalModelAvailability(model) {
+ t.Fatal("first probe result = true, want false")
+ }
+ if calls != 1 {
+ t.Fatalf("probe calls after first failure = %d, want 1", calls)
+ }
+
+ if probeLocalModelAvailability(model) {
+ t.Fatal("cached failed probe result = true, want false")
+ }
+ if calls != 1 {
+ t.Fatalf("probe calls after immediate failed re-check = %d, want 1", calls)
+ }
+
+ now = now.Add(modelProbeFailureBaseInterval)
+ if probeLocalModelAvailability(model) {
+ t.Fatal("second failed probe result = true, want false")
+ }
+ if calls != 2 {
+ t.Fatalf("probe calls after failure backoff window = %d, want 2", calls)
+ }
+
+ now = now.Add(modelProbeFailureBaseInterval)
+ if probeLocalModelAvailability(model) {
+ t.Fatal("cached failure after doubled backoff = true, want false")
+ }
+ if calls != 2 {
+ t.Fatalf("probe calls before doubled failure backoff expires = %d, want 2", calls)
+ }
+
+ now = now.Add(modelProbeFailureBaseInterval)
+ if probeLocalModelAvailability(model) {
+ t.Fatal("third failed probe result = true, want false")
+ }
+ if calls != 3 {
+ t.Fatalf("probe calls after doubled failure backoff expires = %d, want 3", calls)
+ }
+}
+
+func TestProbeLocalModelAvailability_ResultFlipResetsBackoff(t *testing.T) {
+ resetModelProbeHooks(t)
+
+ now := time.Unix(1700000200, 0)
+ modelProbeNowFunc = func() time.Time { return now }
+
+ results := []bool{true, false, false}
+ index := 0
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ if index >= len(results) {
+ return false
+ }
+ result := results[index]
+ index++
+ return result
+ }
+
+ model := &config.ModelConfig{
+ ModelName: "local-vllm",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ }
+
+ if !probeLocalModelAvailability(model) {
+ t.Fatal("first probe result = false, want true")
+ }
+
+ now = now.Add(modelProbeSuccessBaseInterval)
+ if probeLocalModelAvailability(model) {
+ t.Fatal("second probe result = true, want false")
+ }
+
+ now = now.Add(modelProbeFailureBaseInterval)
+ if probeLocalModelAvailability(model) {
+ t.Fatal("third probe result = true, want false")
+ }
+
+ if index != 3 {
+ t.Fatalf("probe invocations = %d, want 3", index)
+ }
+}
+
+func TestProbeLocalModelAvailability_DeduplicatesInflightProbe(t *testing.T) {
+ resetModelProbeHooks(t)
+
+ now := time.Unix(1700000300, 0)
+ modelProbeNowFunc = func() time.Time { return now }
+
+ var calls int32
+ probeStarted := make(chan struct{})
+ releaseProbe := make(chan struct{})
+
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ if atomic.AddInt32(&calls, 1) == 1 {
+ close(probeStarted)
+ }
+ <-releaseProbe
+ return true
+ }
+
+ model := &config.ModelConfig{
+ ModelName: "local-vllm",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ }
+
+ const workers = 8
+ var wg sync.WaitGroup
+ results := make(chan bool, workers)
+ workerStarted := make(chan struct{}, workers)
+
+ for range workers {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ workerStarted <- struct{}{}
+ results <- probeLocalModelAvailability(model)
+ }()
+ }
+
+ for range workers {
+ <-workerStarted
+ }
+
+ select {
+ case <-probeStarted:
+ case <-time.After(200 * time.Millisecond):
+ t.Fatal("probe did not start in time")
+ }
+
+ if got := atomic.LoadInt32(&calls); got != 1 {
+ t.Fatalf("concurrent probe calls = %d, want 1", got)
+ }
+
+ close(releaseProbe)
+ wg.Wait()
+ close(results)
+
+ for result := range results {
+ if !result {
+ t.Fatal("deduplicated probe result = false, want true")
+ }
+ }
+
+ if got := atomic.LoadInt32(&calls); got != 1 {
+ t.Fatalf("final probe calls = %d, want 1", got)
+ }
+}
+
+func TestOllamaModelMatches_WithTagRequiresExactTag(t *testing.T) {
+ if ollamaModelMatches("llama3:8b", "llama3:7b") {
+ t.Fatal("ollamaModelMatches() = true, want false for mismatched tags")
+ }
+ if !ollamaModelMatches("llama3:7b", "llama3:7b") {
+ t.Fatal("ollamaModelMatches() = false, want true for exact tagged match")
+ }
+ if ollamaModelMatches("llama3:8b", "llama3") {
+ t.Fatal("ollamaModelMatches() = true, want false when request omits tag (defaults to latest)")
+ }
+ if !ollamaModelMatches("llama3:latest", "llama3") {
+ t.Fatal("ollamaModelMatches() = false, want true when request omits tag and candidate is latest")
+ }
+ if !ollamaModelMatches("llama3", "llama3") {
+ t.Fatal("ollamaModelMatches() = false, want true when both candidate and request omit tag (latest)")
+ }
+}
diff --git a/web/backend/api/models.go b/web/backend/api/models.go
index 38a55948b..8a66918f9 100644
--- a/web/backend/api/models.go
+++ b/web/backend/api/models.go
@@ -6,10 +6,13 @@ import (
"io"
"net/http"
"strconv"
+ "strings"
"sync"
+ "github.com/sipeed/picoclaw/pkg/audio/asr"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
)
// registerModelRoutes binds model list management endpoints to the ServeMux.
@@ -26,23 +29,201 @@ func (h *Handler) registerModelRoutes(mux *http.ServeMux) {
type modelResponse struct {
Index int `json:"index"`
ModelName string `json:"model_name"`
+ Provider string `json:"provider,omitempty"`
Model string `json:"model"`
APIBase string `json:"api_base,omitempty"`
APIKey string `json:"api_key"`
Proxy string `json:"proxy,omitempty"`
AuthMethod string `json:"auth_method,omitempty"`
// Advanced fields
- ConnectMode string `json:"connect_mode,omitempty"`
- Workspace string `json:"workspace,omitempty"`
- RPM int `json:"rpm,omitempty"`
- MaxTokensField string `json:"max_tokens_field,omitempty"`
- RequestTimeout int `json:"request_timeout,omitempty"`
- ThinkingLevel string `json:"thinking_level,omitempty"`
- ExtraBody map[string]any `json:"extra_body,omitempty"`
+ ConnectMode string `json:"connect_mode,omitempty"`
+ Workspace string `json:"workspace,omitempty"`
+ RPM int `json:"rpm,omitempty"`
+ MaxTokensField string `json:"max_tokens_field,omitempty"`
+ RequestTimeout int `json:"request_timeout,omitempty"`
+ ThinkingLevel string `json:"thinking_level,omitempty"`
+ ToolSchemaTransform string `json:"tool_schema_transform,omitempty"`
+ ExtraBody map[string]any `json:"extra_body,omitempty"`
+ CustomHeaders map[string]string `json:"custom_headers,omitempty"`
// Meta
- Configured bool `json:"configured"`
- IsDefault bool `json:"is_default"`
- IsVirtual bool `json:"is_virtual"`
+ Enabled bool `json:"enabled"`
+ Available bool `json:"available"`
+ Status string `json:"status"`
+ IsDefault bool `json:"is_default"`
+ IsVirtual bool `json:"is_virtual"`
+ DefaultModelAllowed bool `json:"default_model_allowed"`
+}
+
+func normalizeStoredModelConfig(mc *config.ModelConfig) bool {
+ if mc == nil {
+ return false
+ }
+
+ changed := false
+ model := strings.TrimSpace(mc.Model)
+ if model != mc.Model {
+ mc.Model = model
+ changed = true
+ }
+ provider := strings.TrimSpace(mc.Provider)
+ if provider != mc.Provider {
+ mc.Provider = provider
+ changed = true
+ }
+ authMethod := strings.ToLower(strings.TrimSpace(mc.AuthMethod))
+ if authMethod != mc.AuthMethod {
+ mc.AuthMethod = authMethod
+ changed = true
+ }
+
+ if provider != "" {
+ normalizedProvider := providers.NormalizeProvider(provider)
+ if providers.IsSupportedModelProvider(normalizedProvider) && normalizedProvider != provider {
+ mc.Provider = normalizedProvider
+ changed = true
+ }
+ if mc.Provider == "elevenlabs" {
+ if _, strippedModel, found := strings.Cut(
+ model,
+ "/",
+ ); found &&
+ providers.NormalizeProvider(strings.TrimSpace(provider)) == "elevenlabs" {
+ strippedModel = strings.TrimSpace(strippedModel)
+ if strippedModel != "" && strippedModel != mc.Model {
+ mc.Model = strippedModel
+ changed = true
+ }
+ }
+ if strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() {
+ mc.Model = asr.ElevenLabsSupportedModelID()
+ changed = true
+ }
+ }
+ return changed
+ }
+
+ effectiveProvider, modelID := providers.SplitModelProviderAndID(model, "openai")
+ if effectiveProvider == "" {
+ return changed
+ }
+ if mc.Provider != effectiveProvider {
+ mc.Provider = effectiveProvider
+ changed = true
+ }
+ if mc.Model != modelID {
+ mc.Model = modelID
+ changed = true
+ }
+ return changed
+}
+
+func normalizeIncomingModelConfig(mc *config.ModelConfig) {
+ if mc == nil {
+ return
+ }
+
+ mc.Model = strings.TrimSpace(mc.Model)
+ mc.Provider = strings.TrimSpace(mc.Provider)
+ mc.AuthMethod = strings.ToLower(strings.TrimSpace(mc.AuthMethod))
+ if mc.Provider == "" {
+ mc.Provider, mc.Model = providers.SplitModelProviderAndID(mc.Model, "openai")
+ } else {
+ mc.Provider = providers.NormalizeProvider(mc.Provider)
+ if mc.Provider == "elevenlabs" {
+ if _, strippedModel, found := strings.Cut(mc.Model, "/"); found {
+ strippedModel = strings.TrimSpace(strippedModel)
+ if strippedModel != "" {
+ mc.Model = strippedModel
+ }
+ }
+ }
+ }
+ if mc.Provider == "antigravity" && mc.AuthMethod == "" {
+ mc.AuthMethod = "oauth"
+ }
+}
+
+func createAllowedForProvider(provider string) bool {
+ normalized := providers.NormalizeProvider(provider)
+ switch normalized {
+ case "bedrock":
+ // Bedrock currently authenticates through the AWS SDK credential chain
+ // (env vars, shared profiles, IAM roles, etc.), and this Web layer does
+ // not yet have a reliable preflight check for those credential sources.
+ // Keep it creatable in the catalog and let provider construction/runtime
+ // return the concrete AWS error when the environment is incomplete.
+ return true
+ case "claude-cli", "codex-cli":
+ return cliProviderCreateAllowedFromCurrentStatus(normalized)
+ default:
+ return providers.IsCreatableModelProvider(normalized)
+ }
+}
+
+// cliProviderCreateAllowedFromCurrentStatus intentionally reuses the existing
+// local model status pipeline so provider catalog gating follows the same CLI
+// executable probe used by launcher readiness.
+func cliProviderCreateAllowedFromCurrentStatus(provider string) bool {
+ status := modelConfigurationStatus(&config.ModelConfig{
+ Provider: provider,
+ Model: provider,
+ })
+ return status.Available
+}
+
+func modelProviderOptionsForResponse() []providers.ModelProviderOption {
+ options := providers.ModelProviderOptions()
+ for i := range options {
+ options[i].CreateAllowed = createAllowedForProvider(options[i].ID)
+ }
+ return options
+}
+
+func defaultModelAllowedForModelConfig(mc *config.ModelConfig) bool {
+ provider, _ := providers.ExtractProtocol(mc)
+ return providers.IsDefaultModelProvider(provider)
+}
+
+func validateIncomingModelConfig(mc *config.ModelConfig, existing *config.ModelConfig) error {
+ if mc == nil {
+ return fmt.Errorf("model config is required")
+ }
+ if err := mc.Validate(); err != nil {
+ return err
+ }
+ if strings.TrimSpace(mc.Provider) == "" {
+ return fmt.Errorf("provider is required")
+ }
+ if !providers.IsSupportedModelProvider(mc.Provider) {
+ return fmt.Errorf("provider %q is not supported", mc.Provider)
+ }
+ if mc.Provider == "elevenlabs" && strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() {
+ return fmt.Errorf("provider %q only supports model %q", mc.Provider, asr.ElevenLabsSupportedModelID())
+ }
+ if !createAllowedForProvider(mc.Provider) {
+ if existing == nil {
+ return fmt.Errorf("provider %q is not available for new models", mc.Provider)
+ }
+ existingProvider, _ := providers.ExtractProtocol(existing)
+ if providers.NormalizeProvider(existingProvider) != mc.Provider {
+ return fmt.Errorf("provider %q is not available for selection", mc.Provider)
+ }
+ }
+ return nil
+}
+
+func normalizeStoredModelProviders(cfg *config.Config) bool {
+ if cfg == nil {
+ return false
+ }
+
+ changed := false
+ for _, model := range cfg.ModelList {
+ if normalizeStoredModelConfig(model) {
+ changed = true
+ }
+ }
+ return changed
}
// handleListModels returns all model_list entries with masked API keys.
@@ -55,47 +236,59 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
return
}
+ // Normalize legacy provider/model storage in memory so GET can round-trip
+ // through the current API shape without mutating the on-disk config.
+ normalizeStoredModelProviders(cfg)
+
defaultModel := cfg.Agents.Defaults.GetModelName()
- configured := make([]bool, len(cfg.ModelList))
+ modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList))
var wg sync.WaitGroup
wg.Add(len(cfg.ModelList))
for i, m := range cfg.ModelList {
go func(i int, m *config.ModelConfig) {
defer wg.Done()
- configured[i] = isModelConfigured(m)
+ modelStatuses[i] = modelConfigurationStatus(m)
}(i, m)
}
wg.Wait()
models := make([]modelResponse, 0, len(cfg.ModelList))
for i, m := range cfg.ModelList {
+ provider, modelID := providers.ExtractProtocol(m)
models = append(models, modelResponse{
- Index: i,
- ModelName: m.ModelName,
- Model: m.Model,
- APIBase: m.APIBase,
- APIKey: maskAPIKey(m.APIKey()),
- Proxy: m.Proxy,
- AuthMethod: m.AuthMethod,
- ConnectMode: m.ConnectMode,
- Workspace: m.Workspace,
- RPM: m.RPM,
- MaxTokensField: m.MaxTokensField,
- RequestTimeout: m.RequestTimeout,
- ThinkingLevel: m.ThinkingLevel,
- ExtraBody: m.ExtraBody,
- Configured: configured[i],
- IsDefault: m.ModelName == defaultModel,
- IsVirtual: m.IsVirtual(),
+ Index: i,
+ ModelName: m.ModelName,
+ Provider: provider,
+ Model: modelID,
+ APIBase: m.APIBase,
+ APIKey: maskAPIKey(m.APIKey()),
+ Proxy: m.Proxy,
+ AuthMethod: m.AuthMethod,
+ ConnectMode: m.ConnectMode,
+ Workspace: m.Workspace,
+ RPM: m.RPM,
+ MaxTokensField: m.MaxTokensField,
+ RequestTimeout: m.RequestTimeout,
+ ThinkingLevel: m.ThinkingLevel,
+ ToolSchemaTransform: m.ToolSchemaTransform,
+ ExtraBody: m.ExtraBody,
+ CustomHeaders: m.CustomHeaders,
+ Enabled: m.Enabled,
+ Available: modelStatuses[i].Available,
+ Status: modelStatuses[i].Status,
+ IsDefault: m.ModelName == defaultModel,
+ IsVirtual: m.IsVirtual(),
+ DefaultModelAllowed: defaultModelAllowedForModelConfig(m),
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
- "models": models,
- "total": len(models),
- "default_model": defaultModel,
+ "models": models,
+ "total": len(models),
+ "default_model": defaultModel,
+ "provider_options": modelProviderOptionsForResponse(),
})
}
@@ -121,7 +314,9 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
return
}
- if err = mc.Validate(); err != nil {
+ normalizeIncomingModelConfig(&mc.ModelConfig)
+
+ if err = validateIncomingModelConfig(&mc.ModelConfig, nil); err != nil {
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
return
}
@@ -137,6 +332,7 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
}
cfg.ModelList = append(cfg.ModelList, &mc.ModelConfig)
+ normalizeStoredModelProviders(cfg)
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
@@ -170,6 +366,12 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
}
defer r.Body.Close()
+ var rawFields map[string]json.RawMessage
+ if err = json.Unmarshal(body, &rawFields); err != nil {
+ http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
+ return
+ }
+
type custom struct {
config.ModelConfig
APIKey string `json:"api_key"`
@@ -181,11 +383,6 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
return
}
- if err = mc.Validate(); err != nil {
- http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
- return
- }
-
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
@@ -212,8 +409,61 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
} else if len(mc.ExtraBody) == 0 {
mc.ExtraBody = nil
}
+ // Preserve existing CustomHeaders when omitted (nil), but clear it when
+ // the frontend sends an empty object {} to indicate the field should
+ // be removed.
+ if mc.CustomHeaders == nil {
+ mc.CustomHeaders = cfg.ModelList[idx].CustomHeaders
+ } else if len(mc.CustomHeaders) == 0 {
+ mc.CustomHeaders = nil
+ }
+ if _, ok := rawFields["tool_schema_transform"]; !ok {
+ mc.ToolSchemaTransform = cfg.ModelList[idx].ToolSchemaTransform
+ }
+ // Preserve the existing Provider when the caller omits it. This keeps the
+ // update API backward-compatible for clients that haven't started sending
+ // the new field yet, while still allowing explicit clearing via "".
+ if _, ok := rawFields["provider"]; !ok {
+ mc.Provider = cfg.ModelList[idx].Provider
+ // Older clients still round-trip the legacy model field only. When the
+ // stored config encodes provider/model in Model and has no explicit
+ // Provider field yet, continue preserving that hidden provider prefix.
+ // This keeps provider-omitted updates backward-compatible even when an
+ // older client edits the visible model ID.
+ if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" {
+ existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model)
+ incomingModel := strings.TrimSpace(mc.Model)
+ existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx])
+ if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" {
+ if incomingModel == existingModelID {
+ mc.Model = existingRawModel
+ } else if strings.Contains(incomingModel, "/") && !strings.Contains(existingModelID, "/") {
+ // Older clients never saw the hidden provider prefix for simple
+ // legacy entries such as "openai/gpt-4o". If they now send an
+ // explicit provider/model string, treat it as the caller's full
+ // intent instead of re-applying the old hidden prefix.
+ mc.Model = incomingModel
+ } else if !strings.HasPrefix(incomingModel, existingProtocol+"/") {
+ mc.Model = existingProtocol + "/" + incomingModel
+ }
+ }
+ }
+ }
+
+ normalizeIncomingModelConfig(&mc.ModelConfig)
+ if err = validateIncomingModelConfig(&mc.ModelConfig, cfg.ModelList[idx]); err != nil {
+ http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
+ return
+ }
+ if cfg.Agents.Defaults.ModelName == cfg.ModelList[idx].ModelName &&
+ !defaultModelAllowedForModelConfig(&mc.ModelConfig) {
+ // Allow users to recover from legacy/invalid defaults by saving the model
+ // and clearing the default chat model reference in the same write.
+ cfg.Agents.Defaults.ModelName = ""
+ }
cfg.ModelList[idx] = &mc.ModelConfig
+ normalizeStoredModelProviders(cfg)
logger.Debugf("update model config: %#v", mc.ModelConfig)
@@ -313,6 +563,19 @@ func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request)
http.Error(w, fmt.Sprintf("Cannot set virtual model %q as default", req.ModelName), http.StatusBadRequest)
return
}
+ for _, m := range cfg.ModelList {
+ if m.ModelName == req.ModelName {
+ if !defaultModelAllowedForModelConfig(m) {
+ http.Error(
+ w,
+ fmt.Sprintf("Model %q cannot be used as the default chat model", req.ModelName),
+ http.StatusBadRequest,
+ )
+ return
+ }
+ break
+ }
+ }
cfg.Agents.Defaults.ModelName = req.ModelName
diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go
index 97f153a80..0b1f04848 100644
--- a/web/backend/api/models_test.go
+++ b/web/backend/api/models_test.go
@@ -12,6 +12,7 @@ import (
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
)
func resetModelProbeHooks(t *testing.T) {
@@ -20,14 +21,47 @@ func resetModelProbeHooks(t *testing.T) {
origTCPProbe := probeTCPServiceFunc
origOllamaProbe := probeOllamaModelFunc
origOpenAIProbe := probeOpenAICompatibleModelFunc
+ origCommandProbe := probeCommandAvailableFunc
+ origNow := modelProbeNowFunc
+ resetModelProbeCache()
t.Cleanup(func() {
probeTCPServiceFunc = origTCPProbe
probeOllamaModelFunc = origOllamaProbe
probeOpenAICompatibleModelFunc = origOpenAIProbe
+ probeCommandAvailableFunc = origCommandProbe
+ modelProbeNowFunc = origNow
+ resetModelProbeCache()
})
}
-func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *testing.T) {
+func addModelAndLoadLatest(t *testing.T, configPath string, body string) *config.ModelConfig {
+ t.Helper()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if len(cfg.ModelList) == 0 {
+ t.Fatal("model_list should contain the newly added model")
+ }
+
+ return cfg.ModelList[len(cfg.ModelList)-1]
+}
+
+func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
@@ -90,7 +124,8 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
},
}
cfg.Agents.Defaults.ModelName = "openai-oauth"
- if err := config.SaveConfig(configPath, cfg); err != nil {
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
@@ -109,29 +144,47 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
var resp struct {
Models []modelResponse `json:"models"`
}
- if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ err = json.Unmarshal(rec.Body.Bytes(), &resp)
+ if err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
- got := make(map[string]bool, len(resp.Models))
+ gotAvailable := make(map[string]bool, len(resp.Models))
+ gotStatus := make(map[string]string, len(resp.Models))
for _, model := range resp.Models {
- got[model.ModelName] = model.Configured
+ gotAvailable[model.ModelName] = model.Available
+ gotStatus[model.ModelName] = model.Status
}
- if got["openai-oauth"] {
- t.Fatalf("openai oauth model configured = true, want false without stored credential")
+ if gotAvailable["openai-oauth"] {
+ t.Fatalf("openai oauth model available = true, want false without stored credential")
}
- if !got["vllm-local"] {
- t.Fatalf("vllm local model configured = false, want true when local probe succeeds")
+ if !gotAvailable["vllm-local"] {
+ t.Fatalf("vllm local model available = false, want true when local probe succeeds")
}
- if !got["ollama-default"] {
- t.Fatalf("ollama default model configured = false, want true when default local probe succeeds")
+ if !gotAvailable["ollama-default"] {
+ t.Fatalf("ollama default model available = false, want true when default local probe succeeds")
}
- if !got["vllm-remote"] {
- t.Fatalf("remote vllm model configured = false, want true with api_key")
+ if !gotAvailable["vllm-remote"] {
+ t.Fatalf("remote vllm model available = false, want true with api_key")
}
- if !got["copilot-gpt-5.4"] {
- t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds")
+ if !gotAvailable["copilot-gpt-5.4"] {
+ t.Fatalf("copilot model available = false, want true when local bridge probe succeeds")
+ }
+ if gotStatus["openai-oauth"] != modelStatusUnconfigured {
+ t.Fatalf("openai oauth model status = %q, want %q", gotStatus["openai-oauth"], modelStatusUnconfigured)
+ }
+ if gotStatus["vllm-local"] != modelStatusAvailable {
+ t.Fatalf("vllm local model status = %q, want %q", gotStatus["vllm-local"], modelStatusAvailable)
+ }
+ if gotStatus["ollama-default"] != modelStatusAvailable {
+ t.Fatalf("ollama default model status = %q, want %q", gotStatus["ollama-default"], modelStatusAvailable)
+ }
+ if gotStatus["vllm-remote"] != modelStatusAvailable {
+ t.Fatalf("remote vllm model status = %q, want %q", gotStatus["vllm-remote"], modelStatusAvailable)
+ }
+ if gotStatus["copilot-gpt-5.4"] != modelStatusAvailable {
+ t.Fatalf("copilot model status = %q, want %q", gotStatus["copilot-gpt-5.4"], modelStatusAvailable)
}
if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model|" {
t.Fatalf("openAI probes = %#v, want only local vllm probe", openAIProbes)
@@ -144,7 +197,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
}
}
-func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing.T) {
+func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
@@ -160,14 +213,91 @@ func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing
AuthMethod: "oauth",
}}
cfg.Agents.Defaults.ModelName = "claude-oauth"
- if err := config.SaveConfig(configPath, cfg); err != nil {
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
- if err := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{
+ if setCredentialErr := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{
AccessToken: "anthropic-token",
Provider: oauthProviderAnthropic,
AuthMethod: "oauth",
+ }); setCredentialErr != nil {
+ t.Fatalf("SetCredential() error = %v", setCredentialErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ }
+ err = json.Unmarshal(rec.Body.Bytes(), &resp)
+ if err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(resp.Models))
+ }
+ if !resp.Models[0].Available {
+ t.Fatalf("oauth model available = false, want true with stored credential")
+ }
+}
+
+func TestHasModelConfiguration_OAuthWithoutMappedCredentialFallsBackToAPIKey(t *testing.T) {
+ noKey := &config.ModelConfig{
+ Provider: "gemini",
+ Model: "gemini-2.5-flash",
+ AuthMethod: "oauth",
+ }
+ if hasModelConfiguration(noKey) {
+ t.Fatal("oauth model without credential mapping and api key should be unconfigured")
+ }
+
+ withKey := &config.ModelConfig{
+ Provider: "gemini",
+ Model: "gemini-2.5-flash",
+ AuthMethod: "oauth",
+ APIKeys: config.SimpleSecureStrings("gemini-key"),
+ }
+ if !hasModelConfiguration(withKey) {
+ t.Fatal("oauth model without credential mapping should fall back to api key configuration")
+ }
+}
+
+func TestHandleListModels_AntigravityImplicitOAuthAvailability(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+ resetModelProbeHooks(t)
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "gemini-flash",
+ Provider: "antigravity",
+ Model: "gemini-3-flash",
+ }}
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ if err := auth.SetCredential(oauthProviderGoogleAntigravity, &auth.AuthCredential{
+ AccessToken: "antigravity-token",
+ Provider: oauthProviderGoogleAntigravity,
+ AuthMethod: "oauth",
}); err != nil {
t.Fatalf("SetCredential() error = %v", err)
}
@@ -187,14 +317,158 @@ func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing
var resp struct {
Models []modelResponse `json:"models"`
}
- if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
- t.Fatalf("Unmarshal() error = %v", err)
+ if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil {
+ t.Fatalf("Unmarshal() error = %v", unmarshalErr)
}
if len(resp.Models) != 1 {
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
}
- if !resp.Models[0].Configured {
- t.Fatalf("oauth model configured = false, want true with stored credential")
+ if !resp.Models[0].Available {
+ t.Fatal("antigravity model available = false, want true with stored credential even without auth_method")
+ }
+}
+
+func TestHandleListModels_BedrockUsesAmbientCredentialStatus(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+ resetModelProbeHooks(t)
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "bedrock-claude",
+ Provider: "bedrock",
+ Model: "us.anthropic.claude-sonnet-4-20250514-v1:0",
+ }}
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ }
+ if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil {
+ t.Fatalf("Unmarshal() error = %v", unmarshalErr)
+ }
+ if len(resp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(resp.Models))
+ }
+ if !resp.Models[0].Available {
+ t.Fatal("bedrock model available = false, want true because Bedrock uses ambient AWS credentials")
+ }
+ if resp.Models[0].Status != modelStatusAvailable {
+ t.Fatalf("bedrock model status = %q, want %q", resp.Models[0].Status, modelStatusAvailable)
+ }
+}
+
+func TestHandleListModels_CLIProvidersRequireInstalledCommands(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+ resetModelProbeHooks(t)
+
+ probeCommandAvailableFunc = func(command string) bool {
+ switch command {
+ case "claude":
+ return false
+ case "codex":
+ return true
+ default:
+ return false
+ }
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{
+ {
+ ModelName: "claude-cli-model",
+ Provider: "claude-cli",
+ Model: "claude-cli",
+ },
+ {
+ ModelName: "codex-cli-model",
+ Provider: "codex-cli",
+ Model: "codex-cli",
+ },
+ }
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ ProviderOptions []providers.ModelProviderOption `json:"provider_options"`
+ }
+ if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil {
+ t.Fatalf("Unmarshal() error = %v", unmarshalErr)
+ }
+
+ modelsByName := make(map[string]modelResponse, len(resp.Models))
+ for _, model := range resp.Models {
+ modelsByName[model.ModelName] = model
+ }
+ if model := modelsByName["claude-cli-model"]; model.Available || model.Status != modelStatusUnreachable {
+ t.Fatalf(
+ "claude-cli status = (%t, %q), want (%t, %q)",
+ model.Available,
+ model.Status,
+ false,
+ modelStatusUnreachable,
+ )
+ }
+ if model := modelsByName["codex-cli-model"]; !model.Available || model.Status != modelStatusAvailable {
+ t.Fatalf(
+ "codex-cli status = (%t, %q), want (%t, %q)",
+ model.Available,
+ model.Status,
+ true,
+ modelStatusAvailable,
+ )
+ }
+
+ optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions))
+ for _, option := range resp.ProviderOptions {
+ optionsByID[option.ID] = option
+ }
+ if option, ok := optionsByID["claude-cli"]; !ok {
+ t.Fatal("claude-cli provider option missing")
+ } else if option.CreateAllowed {
+ t.Fatal("claude-cli should not be creatable when the claude command is missing")
+ }
+ if option, ok := optionsByID["codex-cli"]; !ok {
+ t.Fatal("codex-cli provider option missing")
+ } else if !option.CreateAllowed {
+ t.Fatal("codex-cli should be creatable when the codex command is available")
}
}
@@ -297,6 +571,59 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ }
+ if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil {
+ t.Fatalf("Unmarshal() error = %v", unmarshalErr)
+ }
+ if len(resp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(resp.Models))
+ }
+ if !resp.Models[0].Available {
+ t.Fatal("wildcard-bound local model available = false, want true after probe host normalization")
+ }
+ if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" {
+ t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|")
+ }
+}
+
+func TestHandleListModels_StatusMarksUnreachableLocalModel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+ resetModelProbeHooks(t)
+
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ return false
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "vllm-local-down",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ APIKeys: config.SimpleSecureStrings("test-key"),
+ }}
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
var resp struct {
Models []modelResponse `json:"models"`
}
@@ -306,11 +633,58 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) {
if len(resp.Models) != 1 {
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
}
- if !resp.Models[0].Configured {
- t.Fatal("wildcard-bound local model configured = false, want true after probe host normalization")
+
+ if resp.Models[0].Available {
+ t.Fatal("unreachable local model available = true, want false")
}
+ if resp.Models[0].Status != modelStatusUnreachable {
+ t.Fatalf("unreachable local model status = %q, want %q", resp.Models[0].Status, modelStatusUnreachable)
+ }
+ if resp.Models[0].APIKey == "" {
+ t.Fatal("masked API key preview should still be returned when API key is configured")
+ }
+}
+
+func TestHandleListModels_RuntimeProbeUsesExplicitProviderField(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+ resetModelProbeHooks(t)
+
+ var gotProbe string
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ gotProbe = apiBase + "|" + modelID + "|" + apiKey
+ return true
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "vllm-local",
+ Provider: "vllm",
+ Model: "custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ }}
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" {
- t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|")
+ t.Fatalf("probe = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|")
}
}
@@ -352,6 +726,1350 @@ func TestHandleAddModel_PersistsAPIKey(t *testing.T) {
}
}
+func TestHandleAddModel_PersistsProvider(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"nvidia-glm",
+ "provider":"nvidia",
+ "model":"z-ai/glm-5.1",
+ "api_key":"nv-key"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ added := cfg.ModelList[len(cfg.ModelList)-1]
+ if added.Provider != "nvidia" {
+ t.Fatalf("provider = %q, want %q", added.Provider, "nvidia")
+ }
+ if added.Model != "z-ai/glm-5.1" {
+ t.Fatalf("model = %q, want %q", added.Model, "z-ai/glm-5.1")
+ }
+}
+
+func TestHandleAddModel_RejectsUnsupportedProvider(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"bad-provider",
+ "provider":"not-supported",
+ "model":"gpt-4o-mini"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), `provider "not-supported" is not supported`) {
+ t.Fatalf("body = %q, want unsupported provider error", rec.Body.String())
+ }
+}
+
+func TestHandleAddModel_AllowsBedrockProvider(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"bedrock-claude",
+ "provider":"bedrock",
+ "model":"us.anthropic.claude-sonnet-4-20250514-v1:0"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ added := cfg.ModelList[len(cfg.ModelList)-1]
+ if got := added.Provider; got != "bedrock" {
+ t.Fatalf("provider = %q, want %q", got, "bedrock")
+ }
+ if got := added.Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" {
+ t.Fatalf("model = %q, want bedrock model ID", got)
+ }
+}
+
+func TestHandleAddModel_NormalizesLegacyElevenLabsASRConfig(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "elevenlabs-asr",
+ Model: "elevenlabs/scribe_v1",
+ APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"new-model",
+ "provider":"openai",
+ "model":"gpt-4o-mini",
+ "api_key":"sk-new-model-key"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if len(updated.ModelList) != 2 {
+ t.Fatalf("len(model_list) = %d, want 2", len(updated.ModelList))
+ }
+ if got := updated.ModelList[0].Provider; got != "elevenlabs" {
+ t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs")
+ }
+ if got := updated.ModelList[0].Model; got != "scribe_v1" {
+ t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1")
+ }
+}
+
+func TestHandleAddModel_NormalizesExplicitElevenLabsUnsupportedModelID(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "elevenlabs-asr",
+ Provider: "elevenlabs",
+ Model: "scribe_v2",
+ APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"new-model",
+ "provider":"openai",
+ "model":"gpt-4o-mini",
+ "api_key":"sk-new-model-key"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "elevenlabs" {
+ t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs")
+ }
+ if got := updated.ModelList[0].Model; got != "scribe_v1" {
+ t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1")
+ }
+}
+
+func TestHandleAddModel_RejectsMissingCLIProviderCommand(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+ resetModelProbeHooks(t)
+
+ probeCommandAvailableFunc = func(command string) bool {
+ return false
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"claude-cli-model",
+ "provider":"claude-cli",
+ "model":"claude-cli"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), `provider "claude-cli" is not available for new models`) {
+ t.Fatalf("body = %q, want missing cli command error", rec.Body.String())
+ }
+}
+
+func TestHandleAddModel_DefaultsAntigravityToOAuth(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ added := addModelAndLoadLatest(t, configPath, `{
+ "model_name":"gemini-flash",
+ "provider":"antigravity",
+ "model":"gemini-3-flash"
+ }`)
+ if got := added.AuthMethod; got != "oauth" {
+ t.Fatalf("auth_method = %q, want %q", got, "oauth")
+ }
+}
+
+func TestHandleAddModel_NormalizesMixedCaseAuthMethod(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ added := addModelAndLoadLatest(t, configPath, `{
+ "model_name":"openai-oauth",
+ "provider":"openai",
+ "model":"gpt-5.4",
+ "auth_method":"OAuth"
+ }`)
+ if got := added.AuthMethod; got != "oauth" {
+ t.Fatalf("auth_method = %q, want %q", got, "oauth")
+ }
+}
+
+func TestHandleAddModel_PreservesExplicitProviderPrefixedModel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"openai-gpt",
+ "provider":"openai",
+ "model":"openai/gpt-4o-mini",
+ "api_key":"sk-openai"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ added := cfg.ModelList[len(cfg.ModelList)-1]
+ if got := added.Provider; got != "openai" {
+ t.Fatalf("provider = %q, want %q", got, "openai")
+ }
+ if got := added.Model; got != "openai/gpt-4o-mini" {
+ t.Fatalf("model = %q, want %q", got, "openai/gpt-4o-mini")
+ }
+}
+
+func TestHandleAddModel_PersistsCustomHeaders(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"new-model-headers",
+ "model":"openai/gpt-4o-mini",
+ "custom_headers":{"X-Source":"coding-plan","X-Agent":"openclaw"}
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if len(cfg.ModelList) != 2 {
+ t.Fatalf("len(model_list) = %d, want 2", len(cfg.ModelList))
+ }
+
+ added := cfg.ModelList[1]
+ if added.CustomHeaders == nil {
+ t.Fatal("custom_headers should not be nil")
+ }
+ if got := added.CustomHeaders["X-Source"]; got != "coding-plan" {
+ t.Fatalf("custom_headers[X-Source] = %q, want %q", got, "coding-plan")
+ }
+ if got := added.CustomHeaders["X-Agent"]; got != "openclaw" {
+ t.Fatalf("custom_headers[X-Agent] = %q, want %q", got, "openclaw")
+ }
+}
+
+func TestHandleAddModel_PersistsToolSchemaTransform(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"new-model-transform",
+ "model":"openai/gpt-4o-mini",
+ "tool_schema_transform":"simple"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ added := cfg.ModelList[len(cfg.ModelList)-1]
+ if got := added.ToolSchemaTransform; got != "simple" {
+ t.Fatalf("tool_schema_transform = %q, want %q", got, "simple")
+ }
+}
+
+func TestHandleUpdateModel_CustomHeadersPreserveAndClear(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "editable",
+ Model: "openai/gpt-4o-mini",
+ APIKeys: config.SimpleSecureStrings("sk-existing"),
+ CustomHeaders: map[string]string{"X-Source": "coding-plan"},
+ }}
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ // Omitted custom_headers should preserve existing value.
+ recPreserve := httptest.NewRecorder()
+ reqPreserve := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"editable",
+ "model":"openai/gpt-4o-mini"
+ }`))
+ reqPreserve.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(recPreserve, reqPreserve)
+ if recPreserve.Code != http.StatusOK {
+ t.Fatalf("preserve status = %d, want %d, body=%s", recPreserve.Code, http.StatusOK, recPreserve.Body.String())
+ }
+
+ afterPreserve, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() after preserve error = %v", err)
+ }
+ if got := afterPreserve.ModelList[0].CustomHeaders["X-Source"]; got != "coding-plan" {
+ t.Fatalf("preserved custom_headers[X-Source] = %q, want %q", got, "coding-plan")
+ }
+
+ // Empty object should clear custom_headers.
+ recClear := httptest.NewRecorder()
+ reqClear := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"editable",
+ "model":"openai/gpt-4o-mini",
+ "custom_headers":{}
+ }`))
+ reqClear.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(recClear, reqClear)
+ if recClear.Code != http.StatusOK {
+ t.Fatalf("clear status = %d, want %d, body=%s", recClear.Code, http.StatusOK, recClear.Body.String())
+ }
+
+ afterClear, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() after clear error = %v", err)
+ }
+ if afterClear.ModelList[0].CustomHeaders != nil {
+ t.Fatalf("custom_headers = %#v, want nil", afterClear.ModelList[0].CustomHeaders)
+ }
+}
+
+func TestHandleUpdateModel_ToolSchemaTransformPreserveAndClear(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "editable",
+ Model: "openai/gpt-4o-mini",
+ APIKeys: config.SimpleSecureStrings("sk-existing"),
+ ToolSchemaTransform: "simple",
+ }}
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ recPreserve := httptest.NewRecorder()
+ reqPreserve := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"editable",
+ "model":"openai/gpt-4o-mini"
+ }`))
+ reqPreserve.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(recPreserve, reqPreserve)
+ if recPreserve.Code != http.StatusOK {
+ t.Fatalf("preserve status = %d, want %d, body=%s", recPreserve.Code, http.StatusOK, recPreserve.Body.String())
+ }
+
+ afterPreserve, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() after preserve error = %v", err)
+ }
+ if got := afterPreserve.ModelList[0].ToolSchemaTransform; got != "simple" {
+ t.Fatalf("preserved tool_schema_transform = %q, want %q", got, "simple")
+ }
+
+ recClear := httptest.NewRecorder()
+ reqClear := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"editable",
+ "model":"openai/gpt-4o-mini",
+ "tool_schema_transform":""
+ }`))
+ reqClear.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(recClear, reqClear)
+ if recClear.Code != http.StatusOK {
+ t.Fatalf("clear status = %d, want %d, body=%s", recClear.Code, http.StatusOK, recClear.Body.String())
+ }
+
+ afterClear, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() after clear error = %v", err)
+ }
+ if afterClear.ModelList[0].ToolSchemaTransform != "" {
+ t.Fatalf("tool_schema_transform = %q, want empty", afterClear.ModelList[0].ToolSchemaTransform)
+ }
+}
+
+func TestHandleUpdateModel_PersistsProvider(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "editable",
+ Model: "gpt-4o",
+ Provider: "openai",
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"editable",
+ "provider":"openrouter",
+ "model":"openai/gpt-4o"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "openrouter" {
+ t.Fatalf("provider = %q, want %q", got, "openrouter")
+ }
+}
+
+func TestHandleUpdateModel_PreservesExplicitProviderPrefixedModel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "editable",
+ Model: "gpt-4o",
+ Provider: "openai",
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"editable",
+ "provider":"openai",
+ "model":"openai/gpt-5.4"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "openai" {
+ t.Fatalf("provider = %q, want %q", got, "openai")
+ }
+ if got := updated.ModelList[0].Model; got != "openai/gpt-5.4" {
+ t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4")
+ }
+}
+
+func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "openrouter-auto-explicit",
+ Provider: "openrouter",
+ Model: "openrouter/auto",
+ }}
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ }
+ err = json.Unmarshal(rec.Body.Bytes(), &resp)
+ if err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(resp.Models))
+ }
+ if got := resp.Models[0].Provider; got != "openrouter" {
+ t.Fatalf("provider = %q, want %q", got, "openrouter")
+ }
+ if got := resp.Models[0].Model; got != "openrouter/auto" {
+ t.Fatalf("model = %q, want %q", got, "openrouter/auto")
+ }
+}
+
+func TestHandleListModels_ExposesElevenLabsASRProvider(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "elevenlabs-asr",
+ Model: "elevenlabs/scribe_v1",
+ APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ }
+ if err = json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(resp.Models))
+ }
+ if got := resp.Models[0].Provider; got != "elevenlabs" {
+ t.Fatalf("provider = %q, want %q", got, "elevenlabs")
+ }
+ if got := resp.Models[0].Model; got != "scribe_v1" {
+ t.Fatalf("model = %q, want %q", got, "scribe_v1")
+ }
+ if resp.Models[0].DefaultModelAllowed {
+ t.Fatal("elevenlabs ASR model should not be allowed as the default chat model")
+ }
+}
+
+func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "legacy-openrouter",
+ Model: "openrouter/openai/gpt-5.4",
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ // Simulate an older client: it reads GET /api/models, ignores the new
+ // provider field, then PUTs the visible model string back unchanged.
+ recList := httptest.NewRecorder()
+ reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(recList, reqList)
+
+ if recList.Code != http.StatusOK {
+ t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String())
+ }
+
+ var listResp struct {
+ Models []modelResponse `json:"models"`
+ }
+ if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(listResp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(listResp.Models))
+ }
+ if got := listResp.Models[0].Provider; got != "openrouter" {
+ t.Fatalf("provider = %q, want %q", got, "openrouter")
+ }
+ if got := listResp.Models[0].Model; got != "openai/gpt-5.4" {
+ t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4")
+ }
+
+ recUpdate := httptest.NewRecorder()
+ reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"legacy-openrouter",
+ "model":"openai/gpt-5.4"
+ }`))
+ reqUpdate.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(recUpdate, reqUpdate)
+
+ if recUpdate.Code != http.StatusOK {
+ t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "openrouter" {
+ t.Fatalf("provider = %q, want %q", got, "openrouter")
+ }
+ if got := updated.ModelList[0].Model; got != "openai/gpt-5.4" {
+ t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4")
+ }
+}
+
+func TestHandleUpdateModel_MigratesLegacyElevenLabsASRWhenProviderOmitted(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "elevenlabs-asr",
+ Model: "elevenlabs/scribe_v1",
+ APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ recList := httptest.NewRecorder()
+ reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(recList, reqList)
+
+ if recList.Code != http.StatusOK {
+ t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String())
+ }
+
+ var listResp struct {
+ Models []modelResponse `json:"models"`
+ }
+ if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(listResp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(listResp.Models))
+ }
+ if got := listResp.Models[0].Provider; got != "elevenlabs" {
+ t.Fatalf("provider = %q, want %q", got, "elevenlabs")
+ }
+ if got := listResp.Models[0].Model; got != "scribe_v1" {
+ t.Fatalf("model = %q, want %q", got, "scribe_v1")
+ }
+
+ recUpdate := httptest.NewRecorder()
+ reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"elevenlabs-asr",
+ "model":"scribe_v1",
+ "api_base":"https://api.elevenlabs.io"
+ }`))
+ reqUpdate.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(recUpdate, reqUpdate)
+
+ if recUpdate.Code != http.StatusOK {
+ t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "elevenlabs" {
+ t.Fatalf("provider = %q, want %q", got, "elevenlabs")
+ }
+ if got := updated.ModelList[0].Model; got != "scribe_v1" {
+ t.Fatalf("model = %q, want %q", got, "scribe_v1")
+ }
+ if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" {
+ t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io")
+ }
+}
+
+func TestHandleUpdateModel_RoundTripsExplicitLegacyElevenLabsModelID(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "elevenlabs-asr",
+ Provider: "elevenlabs",
+ Model: "scribe_v2",
+ APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ recList := httptest.NewRecorder()
+ reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(recList, reqList)
+
+ if recList.Code != http.StatusOK {
+ t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String())
+ }
+
+ var listResp struct {
+ Models []modelResponse `json:"models"`
+ }
+ if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(listResp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(listResp.Models))
+ }
+ if got := listResp.Models[0].Provider; got != "elevenlabs" {
+ t.Fatalf("provider = %q, want %q", got, "elevenlabs")
+ }
+ if got := listResp.Models[0].Model; got != "scribe_v1" {
+ t.Fatalf("model = %q, want %q after GET normalization", got, "scribe_v1")
+ }
+
+ recUpdate := httptest.NewRecorder()
+ reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"elevenlabs-asr",
+ "provider":"elevenlabs",
+ "model":"scribe_v1",
+ "api_base":"https://api.elevenlabs.io"
+ }`))
+ reqUpdate.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(recUpdate, reqUpdate)
+
+ if recUpdate.Code != http.StatusOK {
+ t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "elevenlabs" {
+ t.Fatalf("provider = %q, want %q", got, "elevenlabs")
+ }
+ if got := updated.ModelList[0].Model; got != "scribe_v1" {
+ t.Fatalf("model = %q, want %q", got, "scribe_v1")
+ }
+ if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" {
+ t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io")
+ }
+}
+
+func TestHandleUpdateModel_ClearsDefaultWhenSavingASROnlyModel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "elevenlabs-asr",
+ Provider: "elevenlabs",
+ Model: "scribe_v1",
+ APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
+ }}
+ cfg.Agents.Defaults.ModelName = "elevenlabs-asr"
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"elevenlabs-asr",
+ "provider":"elevenlabs",
+ "model":"scribe_v1",
+ "api_base":"https://api.elevenlabs.io"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.Agents.Defaults.ModelName; got != "" {
+ t.Fatalf("default model = %q, want cleared default", got)
+ }
+}
+
+func TestHandleAddModel_RejectsUnsupportedElevenLabsModelID(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"elevenlabs-asr",
+ "provider":"elevenlabs",
+ "model":"scribe_v2"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), `provider "elevenlabs" only supports model "scribe_v1"`) {
+ t.Fatalf("body = %q, want elevenlabs model validation error", rec.Body.String())
+ }
+}
+
+func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModelChanges(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "legacy-openrouter",
+ Model: "openrouter/openai/gpt-5.4",
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"legacy-openrouter",
+ "model":"openai/gpt-5.5"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "openrouter" {
+ t.Fatalf("provider = %q, want %q", got, "openrouter")
+ }
+ if got := updated.ModelList[0].Model; got != "openai/gpt-5.5" {
+ t.Fatalf("model = %q, want %q", got, "openai/gpt-5.5")
+ }
+}
+
+func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "legacy-openrouter",
+ Model: "openrouter/openai/gpt-5.4",
+ }}
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ ProviderOptions []providers.ModelProviderOption `json:"provider_options"`
+ }
+ if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil {
+ t.Fatalf("Unmarshal() error = %v", unmarshalErr)
+ }
+ if len(resp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(resp.Models))
+ }
+ if got := resp.Models[0].Provider; got != "openrouter" {
+ t.Fatalf("provider = %q, want %q", got, "openrouter")
+ }
+ if got := resp.Models[0].Model; got != "openai/gpt-5.4" {
+ t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4")
+ }
+
+ optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions))
+ for _, option := range resp.ProviderOptions {
+ optionsByID[option.ID] = option
+ }
+ if len(optionsByID) == 0 {
+ t.Fatal("provider_options should not be empty")
+ }
+ if option, ok := optionsByID["openai"]; !ok {
+ t.Fatal("openai provider option missing")
+ } else if option.DefaultAPIBase != "https://api.openai.com/v1" {
+ t.Fatalf("openai default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.openai.com/v1")
+ }
+ if option, ok := optionsByID["anthropic"]; !ok {
+ t.Fatal("anthropic provider option missing")
+ } else if option.DefaultAPIBase != "https://api.anthropic.com/v1" {
+ t.Fatalf("anthropic default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.anthropic.com/v1")
+ }
+ if _, ok := optionsByID["azure"]; !ok {
+ t.Fatal("azure provider option missing")
+ }
+ if option, ok := optionsByID["github-copilot"]; !ok {
+ t.Fatal("github-copilot provider option missing")
+ } else if option.DefaultAPIBase != "localhost:4321" {
+ t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321")
+ }
+ if option, ok := optionsByID["elevenlabs"]; !ok {
+ t.Fatal("elevenlabs provider option missing")
+ } else {
+ if option.DefaultAPIBase != "https://api.elevenlabs.io" {
+ t.Fatalf("elevenlabs default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.elevenlabs.io")
+ }
+ if option.DefaultModelAllowed {
+ t.Fatal("elevenlabs should be marked as not allowed for default chat model selection")
+ }
+ }
+ if option, ok := optionsByID["lmstudio"]; !ok {
+ t.Fatal("lmstudio provider option missing")
+ } else if !option.EmptyAPIKeyAllowed {
+ t.Fatal("lmstudio should allow empty api keys")
+ }
+ if option, ok := optionsByID["bedrock"]; !ok {
+ t.Fatal("bedrock provider option missing")
+ } else if !option.CreateAllowed {
+ t.Fatal("bedrock should stay creatable and defer AWS credential failures to runtime")
+ }
+ if option, ok := optionsByID["antigravity"]; !ok {
+ t.Fatal("antigravity provider option missing")
+ } else {
+ if option.DefaultAuthMethod != "oauth" {
+ t.Fatalf("antigravity default_auth_method = %q, want %q", option.DefaultAuthMethod, "oauth")
+ }
+ if !option.AuthMethodLocked {
+ t.Fatal("antigravity auth method should be locked")
+ }
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "" {
+ t.Fatalf("persisted provider = %q, want unchanged empty provider", got)
+ }
+ if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" {
+ t.Fatalf("persisted model = %q, want unchanged legacy model", got)
+ }
+}
+
+func TestHandleListModels_ReturnsProviderField(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "nvidia-glm",
+ Provider: "nvidia",
+ Model: "z-ai/glm-5.1",
+ APIKeys: config.SimpleSecureStrings("nv-key"),
+ }}
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(resp.Models))
+ }
+ if got := resp.Models[0].Provider; got != "nvidia" {
+ t.Fatalf("provider = %q, want %q", got, "nvidia")
+ }
+}
+
+func TestHandleListModels_PreservesKnownProviderInCatalog(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "bedrock-claude",
+ Model: "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0",
+ }}
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ ProviderOptions []providers.ModelProviderOption `json:"provider_options"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(resp.Models))
+ }
+ if got := resp.Models[0].Provider; got != "bedrock" {
+ t.Fatalf("provider = %q, want %q", got, "bedrock")
+ }
+ if got := resp.Models[0].Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" {
+ t.Fatalf("model = %q, want %q", got, "us.anthropic.claude-sonnet-4-20250514-v1:0")
+ }
+ foundBedrock := false
+ for _, option := range resp.ProviderOptions {
+ if option.ID == "bedrock" {
+ foundBedrock = true
+ if !option.CreateAllowed {
+ t.Fatal("bedrock should stay creatable in provider_options")
+ }
+ }
+ }
+ if !foundBedrock {
+ t.Fatal("bedrock should be included in provider_options for compatibility")
+ }
+}
+
+func TestHandleUpdateModel_AllowsExistingBedrockProvider(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "bedrock-claude",
+ Provider: "bedrock",
+ Model: "us.anthropic.claude-sonnet-4-20250514-v1:0",
+ APIBase: "us-west-2",
+ }}
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"bedrock-claude",
+ "provider":"bedrock",
+ "model":"us.anthropic.claude-3-7-sonnet-20250219-v1:0",
+ "api_base":"us-east-1"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "bedrock" {
+ t.Fatalf("provider = %q, want %q", got, "bedrock")
+ }
+ if got := updated.ModelList[0].Model; got != "us.anthropic.claude-3-7-sonnet-20250219-v1:0" {
+ t.Fatalf("model = %q, want updated bedrock model", got)
+ }
+ if got := updated.ModelList[0].APIBase; got != "us-east-1" {
+ t.Fatalf("api_base = %q, want %q", got, "us-east-1")
+ }
+}
+
+func TestHandleListModels_ReturnsEffectiveProviderField(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{
+ {
+ ModelName: "plain-openai",
+ Model: "gpt-4o",
+ },
+ {
+ ModelName: "explicit-google",
+ Provider: "google",
+ Model: "gemini-2.5-pro",
+ },
+ {
+ ModelName: "explicit-qwen-intl",
+ Provider: "qwen-international",
+ Model: "qwen3-coder-plus",
+ },
+ }
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ if len(resp.Models) != 3 {
+ t.Fatalf("len(models) = %d, want 3", len(resp.Models))
+ }
+
+ if got := resp.Models[0].Provider; got != "openai" {
+ t.Fatalf("provider[0] = %q, want %q", got, "openai")
+ }
+ if got := resp.Models[0].Model; got != "gpt-4o" {
+ t.Fatalf("model[0] = %q, want %q", got, "gpt-4o")
+ }
+ if got := resp.Models[1].Provider; got != "gemini" {
+ t.Fatalf("provider[1] = %q, want %q", got, "gemini")
+ }
+ if got := resp.Models[1].Model; got != "gemini-2.5-pro" {
+ t.Fatalf("model[1] = %q, want %q", got, "gemini-2.5-pro")
+ }
+ if got := resp.Models[2].Provider; got != "qwen-intl" {
+ t.Fatalf("provider[2] = %q, want %q", got, "qwen-intl")
+ }
+ if got := resp.Models[2].Model; got != "qwen3-coder-plus" {
+ t.Fatalf("model[2] = %q, want %q", got, "qwen3-coder-plus")
+ }
+}
+
// TestHandleSetDefaultModel_RejectsNonexistentModel tests that setting a non-existent
// model as default returns 404. This covers the case where virtual models (which are
// filtered by SaveConfig) cannot be set as default.
@@ -392,6 +2110,45 @@ func TestHandleSetDefaultModel_RejectsNonexistentModel(t *testing.T) {
}
}
+func TestHandleSetDefaultModel_RejectsElevenLabsASRProvider(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{
+ {
+ ModelName: "elevenlabs-asr",
+ Provider: "elevenlabs",
+ Model: "scribe_v1",
+ APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
+ },
+ }
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models/default", bytes.NewBufferString(`{
+ "model_name": "elevenlabs-asr"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "cannot be used as the default chat model") {
+ t.Fatalf("body = %q, want default chat model rejection", rec.Body.String())
+ }
+}
+
func TestMaskAPIKey(t *testing.T) {
tests := []struct {
name string
diff --git a/web/backend/api/oauth.go b/web/backend/api/oauth.go
index 213b53836..116e304b1 100644
--- a/web/backend/api/oauth.go
+++ b/web/backend/api/oauth.go
@@ -746,7 +746,7 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {
found := false
for i := range cfg.ModelList {
- if modelBelongsToProvider(provider, cfg.ModelList[i].Model) {
+ if modelBelongsToProvider(provider, cfg.ModelList[i]) {
cfg.ModelList[i].AuthMethod = authMethod
found = true
}
@@ -759,18 +759,15 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {
return oauthSaveConfig(h.configPath, cfg)
}
-func modelBelongsToProvider(provider, model string) bool {
- lower := strings.ToLower(strings.TrimSpace(model))
+func modelBelongsToProvider(provider string, modelCfg *config.ModelConfig) bool {
+ protocol, _ := providers.ExtractProtocol(modelCfg)
switch provider {
case oauthProviderOpenAI:
- return lower == "openai" || strings.HasPrefix(lower, "openai/")
+ return protocol == "openai"
case oauthProviderAnthropic:
- return lower == "anthropic" || strings.HasPrefix(lower, "anthropic/")
+ return protocol == "anthropic"
case oauthProviderGoogleAntigravity:
- return lower == "antigravity" ||
- lower == "google-antigravity" ||
- strings.HasPrefix(lower, "antigravity/") ||
- strings.HasPrefix(lower, "google-antigravity/")
+ return protocol == "antigravity" || protocol == "google-antigravity"
default:
return false
}
@@ -781,19 +778,22 @@ func defaultModelConfigForProvider(provider, authMethod string) *config.ModelCon
case oauthProviderOpenAI:
return &config.ModelConfig{
ModelName: "gpt-5.4",
- Model: "openai/gpt-5.4",
+ Provider: "openai",
+ Model: "gpt-5.4",
AuthMethod: authMethod,
}
case oauthProviderAnthropic:
return &config.ModelConfig{
ModelName: "claude-sonnet-4.6",
- Model: "anthropic/claude-sonnet-4.6",
+ Provider: "anthropic",
+ Model: "claude-sonnet-4.6",
AuthMethod: authMethod,
}
case oauthProviderGoogleAntigravity:
return &config.ModelConfig{
ModelName: "gemini-flash",
- Model: "antigravity/gemini-3-flash",
+ Provider: "antigravity",
+ Model: "gemini-3-flash",
AuthMethod: authMethod,
}
default:
diff --git a/web/backend/api/oauth_test.go b/web/backend/api/oauth_test.go
index 5aaff8d8f..9468c8873 100644
--- a/web/backend/api/oauth_test.go
+++ b/web/backend/api/oauth_test.go
@@ -214,6 +214,54 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
}
}
+func TestOAuthLogoutClearsAuthMethodForExplicitProviderField(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig error: %v", err)
+ }
+ cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{
+ ModelName: "gpt-5.4",
+ Provider: "openai",
+ Model: "gpt-5.4",
+ AuthMethod: "oauth",
+ })
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig error: %v", err)
+ }
+ if err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{
+ AccessToken: "token-before-logout",
+ Provider: oauthProviderOpenAI,
+ AuthMethod: "oauth",
+ }); err != nil {
+ t.Fatalf("SetCredential error: %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/oauth/logout", bytes.NewBufferString(`{"provider":"openai"}`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig error: %v", err)
+ }
+ if got := updated.ModelList[len(updated.ModelList)-1].AuthMethod; got != "" {
+ t.Fatalf("auth_method = %q, want empty", got)
+ }
+}
+
func setupOAuthTestEnv(t *testing.T) (string, func()) {
t.Helper()
diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go
index a3f1a4ffb..8eeff4041 100644
--- a/web/backend/api/pico.go
+++ b/web/backend/api/pico.go
@@ -10,11 +10,13 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ ppid "github.com/sipeed/picoclaw/pkg/pid"
)
// registerPicoRoutes binds Pico Channel management endpoints to the ServeMux.
func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
- mux.HandleFunc("GET /api/pico/token", h.handleGetPicoToken)
+ mux.HandleFunc("GET /api/pico/info", h.handleGetPicoInfo)
mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken)
mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup)
@@ -22,48 +24,191 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
// This allows the frontend to connect via the same port as the web UI,
// avoiding the need to expose extra ports for WebSocket communication.
mux.HandleFunc("GET /pico/ws", h.handleWebSocketProxy())
+ mux.HandleFunc("GET /pico/media/{id}", h.handlePicoMediaProxy())
+ mux.HandleFunc("HEAD /pico/media/{id}", h.handlePicoMediaProxy())
}
// createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint.
// The gateway bind host and port are resolved from the latest configuration.
-func (h *Handler) createWsProxy() *httputil.ReverseProxy {
- wsProxy := httputil.NewSingleHostReverseProxy(h.gatewayProxyURL())
- wsProxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
- http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway)
+func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *httputil.ReverseProxy {
+ wsProxy := &httputil.ReverseProxy{
+ Rewrite: func(r *httputil.ProxyRequest) {
+ target := h.gatewayProxyURL()
+ r.SetURL(target)
+ r.Out.Header.Del(protocolKey)
+ if upstreamProtocol != "" {
+ r.Out.Header.Set(protocolKey, upstreamProtocol)
+ }
+ },
+ ModifyResponse: func(r *http.Response) error {
+ if prot := r.Header.Values(protocolKey); len(prot) > 0 {
+ r.Header.Del(protocolKey)
+ if origProtocol != "" {
+ r.Header.Set(protocolKey, origProtocol)
+ }
+ }
+ return nil
+ },
+ ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
+ logger.Errorf("Failed to proxy WebSocket: %v", err)
+ http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway)
+ },
}
return wsProxy
}
-// handleWebSocketProxy wraps a reverse proxy to handle WebSocket connections.
-// The reverse proxy forwards the incoming upgrade handshake as-is.
-func (h *Handler) handleWebSocketProxy() http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- proxy := h.createWsProxy()
- proxy.ServeHTTP(w, r)
+func (h *Handler) createPicoHTTPProxy(token string) *httputil.ReverseProxy {
+ return &httputil.ReverseProxy{
+ Rewrite: func(r *httputil.ProxyRequest) {
+ target := h.gatewayProxyURL()
+ r.SetURL(target)
+ r.Out.Header.Set("Authorization", "Bearer "+token)
+ },
+ ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
+ logger.Errorf("Failed to proxy Pico HTTP request: %v", err)
+ http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway)
+ },
}
}
-// handleGetPicoToken returns the current WS token and URL for the frontend.
+func (h *Handler) gatewayAvailableForProxy() bool {
+ gateway.mu.Lock()
+ ensurePicoTokenCachedLocked(h.configPath)
+ cachedPID := gateway.pidData
+ trackedCmd := gateway.cmd
+ gateway.mu.Unlock()
+
+ if pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil); pidData != nil {
+ gateway.mu.Lock()
+ gateway.pidData = pidData
+ setGatewayRuntimeStatusLocked("running")
+ gateway.mu.Unlock()
+ return true
+ }
+
+ if cachedPID == nil {
+ return false
+ }
+
+ if isCmdProcessAliveLocked(trackedCmd) {
+ return true
+ }
+
+ gateway.mu.Lock()
+ if gateway.cmd == trackedCmd {
+ gateway.pidData = nil
+ setGatewayRuntimeStatusLocked("stopped")
+ }
+ available := gateway.pidData != nil
+ gateway.mu.Unlock()
+ return available
+}
+
+func decodePicoSettings(cfg *config.Config) (config.PicoSettings, bool) {
+ if cfg == nil {
+ return config.PicoSettings{}, false
+ }
+
+ bc := cfg.Channels.GetByType(config.ChannelPico)
+ if bc == nil {
+ return config.PicoSettings{}, false
+ }
+
+ var picoCfg config.PicoSettings
+ if err := bc.Decode(&picoCfg); err != nil {
+ return config.PicoSettings{}, false
+ }
+
+ return picoCfg, bc.Enabled
+}
+
+func (h *Handler) writePicoInfoResponse(
+ w http.ResponseWriter,
+ r *http.Request,
+ cfg *config.Config,
+ changed *bool,
+) {
+ picoCfg, enabled := decodePicoSettings(cfg)
+
+ resp := map[string]any{
+ "ws_url": h.buildWsURL(r),
+ "enabled": enabled,
+ }
+ if changed != nil {
+ resp["changed"] = *changed
+ }
+ if picoCfg.Token.String() != "" {
+ resp["configured"] = true
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+}
+
+// handleWebSocketProxy wraps a reverse proxy to handle WebSocket connections.
+// It relies on launcher dashboard auth, then injects the raw pico token only
+// on the upstream gateway request.
+func (h *Handler) handleWebSocketProxy() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !h.gatewayAvailableForProxy() {
+ logger.Warnf("Gateway not available for WebSocket proxy")
+ http.Error(w, "Gateway not available", http.StatusServiceUnavailable)
+ return
+ }
+
+ upstreamProtocol := picoGatewayProtocol()
+ if upstreamProtocol == "" {
+ logger.Warn("Pico token unavailable for WebSocket proxy")
+ http.Error(w, "Pico channel not configured", http.StatusServiceUnavailable)
+ return
+ }
+
+ var origProtocol string
+ if prot := r.Header.Values(protocolKey); len(prot) > 0 {
+ origProtocol = prot[0]
+ }
+
+ h.createWsProxy(origProtocol, upstreamProtocol).ServeHTTP(w, r)
+ }
+}
+
+func (h *Handler) handlePicoMediaProxy() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !h.gatewayAvailableForProxy() {
+ logger.Warnf("Gateway not available for Pico media proxy")
+ http.Error(w, "Gateway not available", http.StatusServiceUnavailable)
+ return
+ }
+
+ gateway.mu.Lock()
+ picoToken := gateway.picoToken
+ gateway.mu.Unlock()
+
+ if picoToken == "" {
+ logger.Warnf("Missing Pico token for media proxy")
+ http.Error(w, "Invalid Pico token", http.StatusForbidden)
+ return
+ }
+
+ h.createPicoHTTPProxy(picoToken).ServeHTTP(w, r)
+ }
+}
+
+// handleGetPicoInfo returns non-secret Pico connection info for the launcher UI.
//
-// GET /api/pico/token
-func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
+// GET /api/pico/info
+func (h *Handler) handleGetPicoInfo(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
- wsURL := h.buildWsURL(r)
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]any{
- "token": cfg.Channels.Pico.Token.String(),
- "ws_url": wsURL,
- "enabled": cfg.Channels.Pico.Enabled,
- })
+ h.writePicoInfoResponse(w, r, cfg, nil)
}
-// handleRegenPicoToken generates a new Pico WebSocket token and saves it.
+// handleRegenPicoToken rotates the raw Pico WebSocket token and returns
+// non-secret connection info for the launcher UI.
//
// POST /api/pico/token
func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
@@ -74,30 +219,30 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
}
token := generateSecureToken()
- cfg.Channels.Pico.SetToken(token)
+ if bc := cfg.Channels.GetByType(config.ChannelPico); bc != nil {
+ decoded, err := bc.GetDecoded()
+ if err == nil && decoded != nil {
+ if settings, ok := decoded.(*config.PicoSettings); ok {
+ settings.Token = *config.NewSecureString(token)
+ }
+ }
+ }
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
- wsURL := h.buildWsURL(r)
+ gateway.mu.Lock()
+ gateway.picoToken = token
+ gateway.mu.Unlock()
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]any{
- "token": token,
- "ws_url": wsURL,
- })
+ h.writePicoInfoResponse(w, r, cfg, nil)
}
// EnsurePicoChannel enables the Pico channel with sane defaults if it isn't
// already configured. Returns true when the config was modified.
-//
-// callerOrigin is the Origin header from the setup request. If non-empty and
-// no origins are configured yet, it's written as the allowed origin so the
-// WebSocket handshake works for whatever host the caller is on (LAN, custom
-// port, etc.). Pass "" when there's no request context.
-func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) {
+func (h *Handler) EnsurePicoChannel() (bool, error) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return false, fmt.Errorf("failed to load config: %w", err)
@@ -105,20 +250,24 @@ func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) {
changed := false
- if !cfg.Channels.Pico.Enabled {
- cfg.Channels.Pico.Enabled = true
+ bc := cfg.Channels.GetByType(config.ChannelPico)
+ if bc == nil {
+ bc = &config.Channel{Type: config.ChannelPico}
+ cfg.Channels["pico"] = bc
+ }
+
+ if !bc.Enabled {
+ bc.Enabled = true
changed = true
}
- if cfg.Channels.Pico.Token.String() == "" {
- cfg.Channels.Pico.SetToken(generateSecureToken())
- changed = true
- }
-
- // Seed origins from the request instead of hardcoding ports.
- if len(cfg.Channels.Pico.AllowOrigins) == 0 && callerOrigin != "" {
- cfg.Channels.Pico.AllowOrigins = []string{callerOrigin}
- changed = true
+ if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
+ if picoCfg, ok := decoded.(*config.PicoSettings); ok {
+ if picoCfg.Token.String() == "" {
+ picoCfg.Token = *config.NewSecureString(generateSecureToken())
+ changed = true
+ }
+ }
}
if changed {
@@ -134,27 +283,20 @@ func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) {
//
// POST /api/pico/setup
func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
- changed, err := h.EnsurePicoChannel(r.Header.Get("Origin"))
+ changed, err := h.EnsurePicoChannel()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
+ // Reload config (EnsurePicoChannel may have modified it).
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
- wsURL := h.buildWsURL(r)
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]any{
- "token": cfg.Channels.Pico.Token.String(),
- "ws_url": wsURL,
- "enabled": true,
- "changed": changed,
- })
+ h.writePicoInfoResponse(w, r, cfg, &changed)
}
// generateSecureToken creates a random 32-character hex string.
@@ -162,7 +304,7 @@ func generateSecureToken() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// Fallback to something pseudo-random if crypto/rand fails
- return fmt.Sprintf("pico_%x", time.Now().UnixNano())
+ return fmt.Sprintf("%032x", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go
index aa377975d..6f7cefd4d 100644
--- a/web/backend/api/pico_test.go
+++ b/web/backend/api/pico_test.go
@@ -9,16 +9,24 @@ import (
"os"
"path/filepath"
"strconv"
+ "strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
+ ppid "github.com/sipeed/picoclaw/pkg/pid"
)
+func newPicoProxyRequest(method, path string) *http.Request {
+ req := httptest.NewRequest(method, "http://launcher.local:18800"+path, nil)
+ req.Header.Set("Origin", "http://launcher.local:18800")
+ return req
+}
+
func TestEnsurePicoChannel_FreshConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
- changed, err := h.EnsurePicoChannel("")
+ changed, err := h.EnsurePicoChannel()
if err != nil {
t.Fatalf("EnsurePicoChannel() error = %v", err)
}
@@ -31,10 +39,16 @@ func TestEnsurePicoChannel_FreshConfig(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
- if !cfg.Channels.Pico.Enabled {
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ if !bc.Enabled {
t.Error("expected Pico to be enabled after setup")
}
- if cfg.Channels.Pico.Token.String() == "" {
+ if picoCfg.Token.String() == "" {
t.Error("expected a non-empty token after setup")
}
}
@@ -43,7 +57,7 @@ func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
- if _, err := h.EnsurePicoChannel(""); err != nil {
+ if _, err := h.EnsurePicoChannel(); err != nil {
t.Fatalf("EnsurePicoChannel() error = %v", err)
}
@@ -52,16 +66,22 @@ func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
- if cfg.Channels.Pico.AllowTokenQuery {
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ if picoCfg.AllowTokenQuery {
t.Error("setup must not enable allow_token_query by default")
}
}
-func TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) {
+func TestEnsurePicoChannel_LeavesAllowOriginsEmptyByDefault(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
- if _, err := h.EnsurePicoChannel("http://localhost:18800"); err != nil {
+ if _, err := h.EnsurePicoChannel(); err != nil {
t.Fatalf("EnsurePicoChannel() error = %v", err)
}
@@ -70,18 +90,22 @@ func TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
- for _, origin := range cfg.Channels.Pico.AllowOrigins {
- if origin == "*" {
- t.Error("setup must not set wildcard origin '*'")
- }
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ if len(picoCfg.AllowOrigins) != 0 {
+ t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins)
}
}
-func TestEnsurePicoChannel_NoOriginWithoutCaller(t *testing.T) {
+func TestEnsurePicoChannel_NoOriginConfigurationRequired(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
- if _, err := h.EnsurePicoChannel(""); err != nil {
+ if _, err := h.EnsurePicoChannel(); err != nil {
t.Fatalf("EnsurePicoChannel() error = %v", err)
}
@@ -90,29 +114,14 @@ func TestEnsurePicoChannel_NoOriginWithoutCaller(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
- // Without a caller origin, allow_origins stays empty (CheckOrigin
- // allows all when the list is empty, so the channel still works).
- if len(cfg.Channels.Pico.AllowOrigins) != 0 {
- t.Errorf("allow_origins = %v, want empty when no caller origin", cfg.Channels.Pico.AllowOrigins)
- }
-}
-
-func TestEnsurePicoChannel_SetsCallerOrigin(t *testing.T) {
- configPath := filepath.Join(t.TempDir(), "config.json")
- h := NewHandler(configPath)
-
- lanOrigin := "http://192.168.1.9:18800"
- if _, err := h.EnsurePicoChannel(lanOrigin); err != nil {
- t.Fatalf("EnsurePicoChannel() error = %v", err)
- }
-
- cfg, err := config.LoadConfig(configPath)
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
if err != nil {
- t.Fatalf("LoadConfig() error = %v", err)
+ t.Fatalf("GetDecoded() error = %v", err)
}
-
- if len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != lanOrigin {
- t.Errorf("allow_origins = %v, want [%s]", cfg.Channels.Pico.AllowOrigins, lanOrigin)
+ picoCfg := decoded.(*config.PicoSettings)
+ if len(picoCfg.AllowOrigins) != 0 {
+ t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins)
}
}
@@ -121,17 +130,23 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) {
// Pre-configure with custom user settings
cfg := config.DefaultConfig()
- cfg.Channels.Pico.Enabled = true
- cfg.Channels.Pico.SetToken("user-custom-token")
- cfg.Channels.Pico.AllowTokenQuery = true
- cfg.Channels.Pico.AllowOrigins = []string{"https://myapp.example.com"}
- if err := config.SaveConfig(configPath, cfg); err != nil {
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ bc.Enabled = true
+ picoCfg.SetToken("user-custom-token")
+ picoCfg.AllowTokenQuery = true
+ picoCfg.AllowOrigins = []string{"https://myapp.example.com"}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
- changed, err := h.EnsurePicoChannel("")
+ changed, err := h.EnsurePicoChannel()
if err != nil {
t.Fatalf("EnsurePicoChannel() error = %v", err)
}
@@ -144,14 +159,20 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
- if cfg.Channels.Pico.Token.String() != "user-custom-token" {
- t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token.String(), "user-custom-token")
+ bc = cfg.Channels["pico"]
+ decoded, err = bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
}
- if !cfg.Channels.Pico.AllowTokenQuery {
+ picoCfg = decoded.(*config.PicoSettings)
+ if picoCfg.Token.String() != "user-custom-token" {
+ t.Errorf("token = %q, want %q", picoCfg.Token.String(), "user-custom-token")
+ }
+ if !picoCfg.AllowTokenQuery {
t.Error("user's allow_token_query=true must be preserved")
}
- if len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != "https://myapp.example.com" {
- t.Errorf("allow_origins = %v, want [https://myapp.example.com]", cfg.Channels.Pico.AllowOrigins)
+ if len(picoCfg.AllowOrigins) != 1 || picoCfg.AllowOrigins[0] != "https://myapp.example.com" {
+ t.Errorf("allow_origins = %v, want [https://myapp.example.com]", picoCfg.AllowOrigins)
}
}
@@ -169,7 +190,7 @@ func TestEnsurePicoChannel_ExistingConfigWithoutSecurityFile(t *testing.T) {
h := NewHandler(configPath)
- changed, err := h.EnsurePicoChannel("")
+ changed, err := h.EnsurePicoChannel()
if err != nil {
t.Fatalf("EnsurePicoChannel() error = %v", err)
}
@@ -182,10 +203,16 @@ func TestEnsurePicoChannel_ExistingConfigWithoutSecurityFile(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
- if !cfg.Channels.Pico.Enabled {
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ if !bc.Enabled {
t.Error("expected Pico to be enabled after setup")
}
- if cfg.Channels.Pico.Token.String() == "" {
+ if picoCfg.Token.String() == "" {
t.Error("expected a non-empty token after setup")
}
if _, err := os.Stat(filepath.Join(filepath.Dir(configPath), config.SecurityConfigFile)); err != nil {
@@ -203,7 +230,7 @@ func TestEnsurePicoChannel_ConfiguresPicoWithoutGateway(t *testing.T) {
}
h := NewHandler(configPath)
- if _, err := h.EnsurePicoChannel(""); err != nil {
+ if _, err := h.EnsurePicoChannel(); err != nil {
t.Fatalf("EnsurePicoChannel() error = %v", err)
}
@@ -212,10 +239,16 @@ func TestEnsurePicoChannel_ConfiguresPicoWithoutGateway(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
- if !cfg.Channels.Pico.Enabled {
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ if !bc.Enabled {
t.Error("expected Pico to be enabled after launcher startup setup")
}
- if cfg.Channels.Pico.Token.String() == "" {
+ if picoCfg.Token.String() == "" {
t.Error("expected a non-empty token after launcher startup setup")
}
}
@@ -224,18 +257,22 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
- origin := "http://localhost:18800"
-
// First call sets things up
- if _, err := h.EnsurePicoChannel(origin); err != nil {
+ if _, err := h.EnsurePicoChannel(); err != nil {
t.Fatalf("first EnsurePicoChannel() error = %v", err)
}
cfg1, _ := config.LoadConfig(configPath)
- token1 := cfg1.Channels.Pico.Token.String()
+ bc := cfg1.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ token1 := picoCfg.Token.String()
// Second call should be a no-op
- changed, err := h.EnsurePicoChannel(origin)
+ changed, err := h.EnsurePicoChannel()
if err != nil {
t.Fatalf("second EnsurePicoChannel() error = %v", err)
}
@@ -244,12 +281,18 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) {
}
cfg2, _ := config.LoadConfig(configPath)
- if cfg2.Channels.Pico.Token.String() != token1 {
+ bc = cfg2.Channels["pico"]
+ decoded, err = bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg = decoded.(*config.PicoSettings)
+ if picoCfg.Token.String() != token1 {
t.Error("token should not change on subsequent calls")
}
}
-func TestHandlePicoSetup_IncludesRequestOrigin(t *testing.T) {
+func TestHandlePicoSetup_DoesNotPersistRequestOrigin(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
@@ -268,8 +311,14 @@ func TestHandlePicoSetup_IncludesRequestOrigin(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
- if len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != "http://10.0.0.5:3000" {
- t.Errorf("allow_origins = %v, want [http://10.0.0.5:3000]", cfg.Channels.Pico.AllowOrigins)
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ if len(picoCfg.AllowOrigins) != 0 {
+ t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins)
}
}
@@ -291,8 +340,8 @@ func TestHandlePicoSetup_Response(t *testing.T) {
t.Fatalf("failed to decode response: %v", err)
}
- if resp["token"] == nil || resp["token"] == "" {
- t.Error("response should contain a non-empty token")
+ if _, ok := resp["token"]; ok {
+ t.Error("response must not expose the raw pico token")
}
if resp["ws_url"] == nil || resp["ws_url"] == "" {
t.Error("response should contain ws_url")
@@ -303,9 +352,107 @@ func TestHandlePicoSetup_Response(t *testing.T) {
if resp["changed"] != true {
t.Error("response should have changed=true on first setup")
}
+ if resp["configured"] != true {
+ t.Error("response should have configured=true")
+ }
+}
+
+func TestHandleGetPicoInfo_OmitsToken(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+
+ if _, err := h.EnsurePicoChannel(); err != nil {
+ t.Fatalf("EnsurePicoChannel() error = %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "http://launcher.local/api/pico/info", nil)
+ rec := httptest.NewRecorder()
+
+ h.handleGetPicoInfo(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ var resp map[string]any
+ if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if _, ok := resp["token"]; ok {
+ t.Fatal("info response must not expose the raw pico token")
+ }
+ if resp["enabled"] != true {
+ t.Fatalf("enabled = %#v, want true", resp["enabled"])
+ }
+ if resp["configured"] != true {
+ t.Fatalf("configured = %#v, want true", resp["configured"])
+ }
+ if resp["ws_url"] == nil || resp["ws_url"] == "" {
+ t.Fatal("response should contain ws_url")
+ }
+}
+
+func TestHandleRegenPicoToken_RefreshesGatewayTokenCache(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+
+ if _, err := h.EnsurePicoChannel(); err != nil {
+ t.Fatalf("EnsurePicoChannel() error = %v", err)
+ }
+
+ origPicoToken := gateway.picoToken
+ t.Cleanup(func() {
+ gateway.mu.Lock()
+ gateway.picoToken = origPicoToken
+ gateway.mu.Unlock()
+ })
+
+ gateway.mu.Lock()
+ gateway.picoToken = "stale-token"
+ gateway.mu.Unlock()
+
+ req := httptest.NewRequest(http.MethodPost, "http://launcher.local/api/pico/token", nil)
+ rec := httptest.NewRecorder()
+ h.handleRegenPicoToken(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ token := decoded.(*config.PicoSettings).Token.String()
+ if token == "" {
+ t.Fatal("expected regenerated pico token to be persisted")
+ }
+ if token == "stale-token" {
+ t.Fatal("expected regenerated pico token to differ from stale cache")
+ }
+
+ gateway.mu.Lock()
+ defer gateway.mu.Unlock()
+ if gateway.picoToken != token {
+ t.Fatalf("gateway.picoToken = %q, want %q", gateway.picoToken, token)
+ }
}
func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) {
+ origMatcher := gatewayProcessMatcher
+ gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
+ t.Cleanup(func() { gatewayProcessMatcher = origMatcher })
+
+ home := t.TempDir()
+ t.Setenv("PICOCLAW_HOME", home)
+
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
handler := h.handleWebSocketProxy()
@@ -334,8 +481,30 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) {
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+ writeTestPidFile(t, ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "test-token",
+ Host: cfg.Gateway.Host,
+ Port: cfg.Gateway.Port,
+ })
+ origPidData := gateway.pidData
+ origPicoToken := gateway.picoToken
+ t.Cleanup(func() {
+ ppid.RemovePidFile(globalConfigDir())
+ gateway.pidData = origPidData
+ gateway.picoToken = origPicoToken
+ })
- req1 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil)
+ gateway.pidData = &ppid.PidFileData{}
+ gateway.picoToken = "pico"
+ req1 := newPicoProxyRequest(http.MethodGet, "/pico/ws")
rec1 := httptest.NewRecorder()
handler(rec1, req1)
@@ -351,7 +520,7 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) {
t.Fatalf("SaveConfig() error = %v", err)
}
- req2 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil)
+ req2 := newPicoProxyRequest(http.MethodGet, "/pico/ws")
rec2 := httptest.NewRecorder()
handler(rec2, req2)
@@ -363,6 +532,428 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) {
}
}
+func TestHandleWebSocketProxyLoadsCachedPicoTokenWhenMissing(t *testing.T) {
+ origMatcher := gatewayProcessMatcher
+ gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
+ t.Cleanup(func() { gatewayProcessMatcher = origMatcher })
+
+ home := t.TempDir()
+ t.Setenv("PICOCLAW_HOME", home)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ handler := h.handleWebSocketProxy()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/pico/ws" {
+ t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/ws")
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = io.WriteString(w, "proxied")
+ }))
+ defer server.Close()
+
+ cfg := config.DefaultConfig()
+ cfg.Gateway.Host = "127.0.0.1"
+ cfg.Gateway.Port = mustGatewayTestPort(t, server.URL)
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ bc.Enabled = true
+ picoCfg.SetToken("cached-token")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+ writeTestPidFile(t, ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "test-token",
+ Host: cfg.Gateway.Host,
+ Port: cfg.Gateway.Port,
+ })
+ t.Cleanup(func() {
+ ppid.RemovePidFile(globalConfigDir())
+ })
+
+ origPidData := gateway.pidData
+ origPicoToken := gateway.picoToken
+ t.Cleanup(func() {
+ gateway.pidData = origPidData
+ gateway.picoToken = origPicoToken
+ })
+
+ gateway.pidData = &ppid.PidFileData{}
+ gateway.picoToken = ""
+
+ req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session")
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+ if body := rec.Body.String(); body != "proxied" {
+ t.Fatalf("body = %q, want %q", body, "proxied")
+ }
+ if gateway.picoToken != "cached-token" {
+ t.Fatalf("gateway.picoToken = %q, want %q", gateway.picoToken, "cached-token")
+ }
+}
+
+func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) {
+ origMatcher := gatewayProcessMatcher
+ gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
+ t.Cleanup(func() { gatewayProcessMatcher = origMatcher })
+
+ home := t.TempDir()
+ t.Setenv("PICOCLAW_HOME", home)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ handler := h.handleWebSocketProxy()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/pico/ws" {
+ t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/ws")
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = io.WriteString(w, r.Header.Get(protocolKey))
+ }))
+ defer server.Close()
+
+ cfg := config.DefaultConfig()
+ cfg.Gateway.Host = "127.0.0.1"
+ cfg.Gateway.Port = mustGatewayTestPort(t, server.URL)
+ bc := cfg.Channels["pico"]
+ bc.Enabled = true
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ decoded.(*config.PicoSettings).SetToken("ui-token")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+ pidData := ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "test-token",
+ Host: cfg.Gateway.Host,
+ Port: cfg.Gateway.Port,
+ }
+ writeTestPidFile(t, pidData)
+ t.Cleanup(func() {
+ ppid.RemovePidFile(globalConfigDir())
+ })
+
+ origPidData := gateway.pidData
+ origPicoToken := gateway.picoToken
+ origStatus := gateway.runtimeStatus
+ t.Cleanup(func() {
+ gateway.mu.Lock()
+ gateway.pidData = origPidData
+ gateway.picoToken = origPicoToken
+ gateway.runtimeStatus = origStatus
+ gateway.mu.Unlock()
+ })
+
+ gateway.mu.Lock()
+ gateway.pidData = nil
+ gateway.picoToken = ""
+ setGatewayRuntimeStatusLocked("stopped")
+ gateway.mu.Unlock()
+
+ req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session")
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ expected := tokenPrefix + "ui-token"
+ if got := rec.Body.String(); got != expected {
+ t.Fatalf("forwarded protocol = %q, want %q", got, expected)
+ }
+
+ gateway.mu.Lock()
+ defer gateway.mu.Unlock()
+ if gateway.pidData == nil {
+ t.Fatal("gateway.pidData should be loaded from pid file")
+ }
+ if gateway.runtimeStatus != "running" {
+ t.Fatalf("runtimeStatus = %q, want %q", gateway.runtimeStatus, "running")
+ }
+}
+
+func TestCreatePicoHTTPProxyInjectsGatewayAuth(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+
+ cfg := config.DefaultConfig()
+ cfg.Gateway.Host = "127.0.0.1"
+ cfg.Gateway.Port = 18790
+ bc := cfg.Channels["pico"]
+ bc.Enabled = true
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ decoded.(*config.PicoSettings).SetToken("ui-token")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ proxy := h.createPicoHTTPProxy("ui-token")
+ var capturedPath string
+ var capturedAuth string
+ proxy.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ capturedPath = req.URL.Path
+ capturedAuth = req.Header.Get("Authorization")
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader("proxied")),
+ Request: req,
+ }, nil
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/pico/media/attachment-1", nil)
+ rec := httptest.NewRecorder()
+ proxy.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+ if capturedPath != "/pico/media/attachment-1" {
+ t.Fatalf("capturedPath = %q, want %q", capturedPath, "/pico/media/attachment-1")
+ }
+ expected := "Bearer ui-token"
+ if capturedAuth != expected {
+ t.Fatalf("Authorization = %q, want %q", capturedAuth, expected)
+ }
+}
+
+func TestHandlePicoMediaProxyUsesRawBearerToken(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("PICOCLAW_HOME", home)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ handler := h.handlePicoMediaProxy()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/pico/media/attachment-1" {
+ t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/media/attachment-1")
+ }
+ if got := r.Header.Get("Authorization"); got != "Bearer ui-token" {
+ t.Fatalf("Authorization = %q, want %q", got, "Bearer ui-token")
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = io.WriteString(w, "proxied-media")
+ }))
+ defer server.Close()
+
+ cfg := config.DefaultConfig()
+ cfg.Gateway.Host = "127.0.0.1"
+ cfg.Gateway.Port = mustGatewayTestPort(t, server.URL)
+ bc := cfg.Channels["pico"]
+ bc.Enabled = true
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ decoded.(*config.PicoSettings).SetToken("ui-token")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+
+ origPidData := gateway.pidData
+ origPicoToken := gateway.picoToken
+ origCmd := gateway.cmd
+ t.Cleanup(func() {
+ gateway.mu.Lock()
+ gateway.pidData = origPidData
+ gateway.picoToken = origPicoToken
+ gateway.cmd = origCmd
+ gateway.mu.Unlock()
+ })
+
+ gateway.mu.Lock()
+ gateway.pidData = &ppid.PidFileData{PID: cmd.Process.Pid}
+ gateway.picoToken = "ui-token"
+ gateway.cmd = cmd
+ gateway.mu.Unlock()
+
+ req := newPicoProxyRequest(http.MethodGet, "/pico/media/attachment-1")
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+ if body := rec.Body.String(); body != "proxied-media" {
+ t.Fatalf("body = %q, want %q", body, "proxied-media")
+ }
+}
+
+func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Setenv("HOME", tmpDir)
+ t.Setenv("PICOCLAW_HOME", filepath.Join(tmpDir, ".picoclaw"))
+
+ configPath := filepath.Join(tmpDir, "config.json")
+ h := NewHandler(configPath)
+ handler := h.handleWebSocketProxy()
+
+ cfg := config.DefaultConfig()
+ bc := cfg.Channels["pico"]
+ bc.Enabled = true
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ decoded.(*config.PicoSettings).SetToken("ui-token")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ cmd := startLongRunningProcess(t)
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+
+ origPidData := gateway.pidData
+ origPicoToken := gateway.picoToken
+ origCmd := gateway.cmd
+ origStatus := gateway.runtimeStatus
+ t.Cleanup(func() {
+ gateway.mu.Lock()
+ gateway.pidData = origPidData
+ gateway.picoToken = origPicoToken
+ gateway.cmd = origCmd
+ gateway.runtimeStatus = origStatus
+ gateway.mu.Unlock()
+ })
+
+ gateway.mu.Lock()
+ gateway.pidData = &ppid.PidFileData{PID: cmd.Process.Pid, Token: "stale-token"}
+ gateway.picoToken = "ui-token"
+ gateway.cmd = cmd
+ setGatewayRuntimeStatusLocked("running")
+ gateway.mu.Unlock()
+
+ req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session")
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
+ }
+ gateway.mu.Lock()
+ defer gateway.mu.Unlock()
+ if gateway.pidData != nil {
+ t.Fatal("gateway.pidData should be cleared after stale process exit is detected")
+ }
+}
+
+func TestHandleWebSocketProxy_AllowsArbitraryOrigin(t *testing.T) {
+ origMatcher := gatewayProcessMatcher
+ gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
+ t.Cleanup(func() { gatewayProcessMatcher = origMatcher })
+
+ home := t.TempDir()
+ t.Setenv("PICOCLAW_HOME", home)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ handler := h.handleWebSocketProxy()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/pico/ws" {
+ t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/ws")
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = io.WriteString(w, "proxied")
+ }))
+ defer server.Close()
+
+ cfg := config.DefaultConfig()
+ cfg.Gateway.Host = "127.0.0.1"
+ cfg.Gateway.Port = mustGatewayTestPort(t, server.URL)
+ bc := cfg.Channels["pico"]
+ bc.Enabled = true
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ decoded.(*config.PicoSettings).SetToken("ui-token")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+ writeTestPidFile(t, ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "test-token",
+ Host: cfg.Gateway.Host,
+ Port: cfg.Gateway.Port,
+ })
+ t.Cleanup(func() {
+ ppid.RemovePidFile(globalConfigDir())
+ })
+
+ origPidData := gateway.pidData
+ origPicoToken := gateway.picoToken
+ t.Cleanup(func() {
+ gateway.pidData = origPidData
+ gateway.picoToken = origPicoToken
+ })
+
+ gateway.pidData = &ppid.PidFileData{}
+ gateway.picoToken = "ui-token"
+
+ req := httptest.NewRequest(http.MethodGet, "http://launcher.local/pico/ws?session_id=test-session", nil)
+ req.Header.Set("Origin", "http://evil.example")
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+}
+
func mustGatewayTestPort(t *testing.T, rawURL string) int {
t.Helper()
@@ -378,3 +969,9 @@ func mustGatewayTestPort(t *testing.T, rawURL string) int {
return port
}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return fn(req)
+}
diff --git a/web/backend/api/router.go b/web/backend/api/router.go
index ce652d4c4..76f63607e 100644
--- a/web/backend/api/router.go
+++ b/web/backend/api/router.go
@@ -2,6 +2,7 @@ package api
import (
"net/http"
+ "strings"
"sync"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
@@ -13,7 +14,10 @@ type Handler struct {
serverPort int
serverPublic bool
serverPublicExplicit bool
+ serverHostInput string
+ serverHostExplicit bool
serverCIDRs []string
+ debug bool
oauthMu sync.Mutex
oauthFlows map[string]*oauthFlow
oauthState map[string]string
@@ -40,9 +44,25 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a
h.serverPort = port
h.serverPublic = public
h.serverPublicExplicit = publicExplicit
+ h.serverHostInput = ""
+ h.serverHostExplicit = false
h.serverCIDRs = append([]string(nil), allowedCIDRs...)
}
+// SetServerBindHost stores the launcher's effective bind host.
+// When explicit is true, hostInput is the normalized -host / PICOCLAW_LAUNCHER_HOST value.
+func (h *Handler) SetServerBindHost(hostInput string, explicit bool) {
+ h.serverHostInput = strings.TrimSpace(hostInput)
+ if !explicit {
+ h.serverHostInput = ""
+ }
+ h.serverHostExplicit = explicit
+}
+
+func (h *Handler) SetDebug(debug bool) {
+ h.debug = debug
+}
+
// RegisterRoutes binds all API endpoint handlers to the ServeMux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Config CRUD
@@ -76,6 +96,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Launcher service parameters (port/public)
h.registerLauncherConfigRoutes(mux)
+ // Self-update endpoint (requires dashboard auth)
+ h.registerUpdateRoutes(mux)
+
+ // Runtime build/version metadata
+ h.registerVersionRoutes(mux)
+
// WeChat QR login flow
h.registerWeixinRoutes(mux)
diff --git a/web/backend/api/session.go b/web/backend/api/session.go
index 42d451a05..cc18ee6e1 100644
--- a/web/backend/api/session.go
+++ b/web/backend/api/session.go
@@ -13,7 +13,11 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/providers/messageutil"
+ "github.com/sipeed/picoclaw/pkg/session"
+ "github.com/sipeed/picoclaw/pkg/utils"
)
// registerSessionRoutes binds session list and detail endpoints to the ServeMux.
@@ -42,52 +46,58 @@ type sessionListItem struct {
Updated string `json:"updated"`
}
-type sessionMetaFile struct {
- Key string `json:"key"`
- Summary string `json:"summary"`
- Skip int `json:"skip"`
- Count int `json:"count"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+type sessionChatMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ Kind string `json:"kind,omitempty"`
+ Media []string `json:"media,omitempty"`
+ Attachments []sessionChatAttachment `json:"attachments,omitempty"`
+ ToolCalls []utils.VisibleToolCall `json:"tool_calls,omitempty"`
}
-// picoSessionPrefix is the key prefix used by the gateway's routing for Pico
-// channel sessions. The full key format is:
-//
-// agent:main:pico:direct:pico:
-//
-// The sanitized filename replaces ':' with '_', so on disk it becomes:
-//
-// agent_main_pico_direct_pico_.json
+type sessionChatAttachment struct {
+ Type string `json:"type,omitempty"`
+ URL string `json:"url,omitempty"`
+ Filename string `json:"filename,omitempty"`
+ ContentType string `json:"content_type,omitempty"`
+}
+
+// legacyPicoSessionPrefix is the legacy key prefix used by older Pico JSON/JSONL
+// sessions before structured scope metadata existed.
const (
- picoSessionPrefix = "agent:main:pico:direct:pico:"
- sanitizedPicoSessionPrefix = "agent_main_pico_direct_pico_"
- maxSessionJSONLLineSize = 10 * 1024 * 1024 // 10 MB
- maxSessionTitleRunes = 60
+ legacyPicoSessionPrefix = "agent:main:pico:direct:pico:"
+ picoSessionPrefix = legacyPicoSessionPrefix
+
+ // Keep the session API aligned with the shared JSONL store reader limit in
+ // pkg/memory/jsonl.go so oversized lines fail consistently everywhere.
+ maxSessionJSONLLineSize = 10 * 1024 * 1024
+ maxSessionTitleRunes = 60
+
+ handledToolResponseSummaryText = "Requested output delivered via tool attachment."
)
-// extractPicoSessionID extracts the session UUID from a full session key.
-// Returns the UUID and true if the key matches the Pico session pattern.
-func extractPicoSessionID(key string) (string, bool) {
- if strings.HasPrefix(key, picoSessionPrefix) {
- return strings.TrimPrefix(key, picoSessionPrefix), true
- }
- return "", false
+func defaultToolFeedbackMaxArgsLength() int {
+ defaults := config.AgentDefaults{}
+ return defaults.GetToolFeedbackMaxArgsLength()
}
-func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) {
- if strings.HasPrefix(key, sanitizedPicoSessionPrefix) {
- return strings.TrimPrefix(key, sanitizedPicoSessionPrefix), true
+// extractLegacyPicoSessionID extracts the session UUID from an old Pico key.
+// Returns the UUID and true if the key matches the Pico session pattern.
+func extractLegacyPicoSessionID(key string) (string, bool) {
+ if strings.HasPrefix(key, legacyPicoSessionPrefix) {
+ return strings.TrimPrefix(key, legacyPicoSessionPrefix), true
}
return "", false
}
func sanitizeSessionKey(key string) string {
- return strings.ReplaceAll(key, ":", "_")
+ key = strings.ReplaceAll(key, ":", "_")
+ key = strings.ReplaceAll(key, "/", "_")
+ key = strings.ReplaceAll(key, "\\", "_")
+ return key
}
-func (h *Handler) readLegacySession(dir, sessionID string) (sessionFile, error) {
- path := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)+".json")
+func (h *Handler) readLegacySession(path string) (sessionFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return sessionFile{}, err
@@ -100,18 +110,18 @@ func (h *Handler) readLegacySession(dir, sessionID string) (sessionFile, error)
return sess, nil
}
-func (h *Handler) readSessionMeta(path, sessionKey string) (sessionMetaFile, error) {
+func (h *Handler) readSessionMeta(path, sessionKey string) (memory.SessionMeta, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
- return sessionMetaFile{Key: sessionKey}, nil
+ return memory.SessionMeta{Key: sessionKey}, nil
}
if err != nil {
- return sessionMetaFile{}, err
+ return memory.SessionMeta{}, err
}
- var meta sessionMetaFile
+ var meta memory.SessionMeta
if err := json.Unmarshal(data, &meta); err != nil {
- return sessionMetaFile{}, err
+ return memory.SessionMeta{}, err
}
if meta.Key == "" {
meta.Key = sessionKey
@@ -146,6 +156,9 @@ func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Messag
if err := json.Unmarshal(line, &msg); err != nil {
continue
}
+ if messageutil.IsTransientAssistantThoughtMessage(msg) {
+ continue
+ }
msgs = append(msgs, msg)
}
if err := scanner.Err(); err != nil {
@@ -154,8 +167,7 @@ func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Messag
return msgs, nil
}
-func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {
- sessionKey := picoSessionPrefix + sessionID
+func (h *Handler) readJSONLSession(dir, sessionKey string) (sessionFile, error) {
base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
jsonlPath := base + ".jsonl"
metaPath := base + ".meta.json"
@@ -192,41 +204,237 @@ func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {
}, nil
}
-func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem {
+type picoJSONLSessionRef struct {
+ ID string
+ Key string
+}
+
+type picoLegacySessionRef struct {
+ ID string
+ Path string
+}
+
+func extractPicoSessionIDFromScope(scope session.SessionScope) (string, bool) {
+ if !strings.EqualFold(strings.TrimSpace(scope.Channel), "pico") {
+ return "", false
+ }
+
+ candidates := []string{
+ strings.TrimSpace(scope.Values["sender"]),
+ strings.TrimSpace(scope.Values["chat"]),
+ }
+ for _, candidate := range candidates {
+ if candidate == "" {
+ continue
+ }
+ if idx := strings.Index(candidate, "pico:"); idx >= 0 {
+ sessionID := strings.TrimSpace(candidate[idx+len("pico:"):])
+ if sessionID != "" {
+ return sessionID, true
+ }
+ }
+ }
+ return "", false
+}
+
+func sessionRefFromMeta(meta memory.SessionMeta) (picoJSONLSessionRef, bool) {
+ if len(meta.Scope) == 0 {
+ if sessionID, ok := extractLegacyPicoSessionID(meta.Key); ok {
+ return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true
+ }
+ for _, alias := range meta.Aliases {
+ if sessionID, ok := extractLegacyPicoSessionID(alias); ok {
+ return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true
+ }
+ }
+ return picoJSONLSessionRef{}, false
+ }
+ var scope session.SessionScope
+ if err := json.Unmarshal(meta.Scope, &scope); err != nil {
+ return picoJSONLSessionRef{}, false
+ }
+ sessionID, ok := extractPicoSessionIDFromScope(scope)
+ if !ok {
+ if legacySessionID, ok := extractLegacyPicoSessionID(meta.Key); ok {
+ return picoJSONLSessionRef{ID: legacySessionID, Key: meta.Key}, true
+ }
+ for _, alias := range meta.Aliases {
+ if legacySessionID, ok := extractLegacyPicoSessionID(alias); ok {
+ return picoJSONLSessionRef{ID: legacySessionID, Key: meta.Key}, true
+ }
+ }
+ return picoJSONLSessionRef{}, false
+ }
+ return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true
+}
+
+func (h *Handler) findPicoJSONLSessions(dir string) ([]picoJSONLSessionRef, error) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+
+ refs := make([]picoJSONLSessionRef, 0)
+ seen := make(map[string]struct{})
+ metaBackedBases := make(map[string]struct{})
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta.json") {
+ continue
+ }
+ name := entry.Name()
+ metaPath := filepath.Join(dir, name)
+ meta, err := h.readSessionMeta(metaPath, "")
+ if err != nil {
+ continue
+ }
+ ref, ok := sessionRefFromMeta(meta)
+ if !ok || ref.Key == "" || ref.ID == "" {
+ continue
+ }
+ metaBackedBases[strings.TrimSuffix(name, ".meta.json")] = struct{}{}
+ if _, exists := seen[ref.ID]; exists {
+ continue
+ }
+ seen[ref.ID] = struct{}{}
+ refs = append(refs, ref)
+ }
+
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
+ continue
+ }
+ name := entry.Name()
+ base := strings.TrimSuffix(name, ".jsonl")
+ if _, ok := metaBackedBases[base]; ok {
+ continue
+ }
+ ref, ok := jsonlSessionRefFromFilename(name)
+ if !ok || ref.Key == "" || ref.ID == "" {
+ continue
+ }
+ if _, exists := seen[ref.ID]; exists {
+ continue
+ }
+ seen[ref.ID] = struct{}{}
+ refs = append(refs, ref)
+ }
+ return refs, nil
+}
+
+func (h *Handler) findPicoJSONLSession(dir, sessionID string) (picoJSONLSessionRef, error) {
+ refs, err := h.findPicoJSONLSessions(dir)
+ if err != nil {
+ return picoJSONLSessionRef{}, err
+ }
+ for _, ref := range refs {
+ if ref.ID == sessionID {
+ return ref, nil
+ }
+ }
+ return picoJSONLSessionRef{}, os.ErrNotExist
+}
+
+func (h *Handler) findLegacyPicoSessions(dir string) ([]picoLegacySessionRef, error) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+
+ refs := make([]picoLegacySessionRef, 0)
+ seen := make(map[string]struct{})
+ for _, entry := range entries {
+ name := entry.Name()
+ if entry.IsDir() || filepath.Ext(name) != ".json" || strings.HasSuffix(name, ".meta.json") {
+ continue
+ }
+
+ path := filepath.Join(dir, entry.Name())
+ sess, err := h.readLegacySession(path)
+ if err != nil || isEmptySession(sess) {
+ continue
+ }
+
+ sessionID, ok := extractLegacyPicoSessionID(sess.Key)
+ if !ok || sessionID == "" {
+ continue
+ }
+ if _, exists := seen[sessionID]; exists {
+ continue
+ }
+ seen[sessionID] = struct{}{}
+ refs = append(refs, picoLegacySessionRef{ID: sessionID, Path: path})
+ }
+ return refs, nil
+}
+
+func jsonlSessionRefFromFilename(name string) (picoJSONLSessionRef, bool) {
+ if !strings.HasSuffix(name, ".jsonl") {
+ return picoJSONLSessionRef{}, false
+ }
+ base := strings.TrimSuffix(name, ".jsonl")
+ if base == "" {
+ return picoJSONLSessionRef{}, false
+ }
+
+ legacyPrefix := sanitizeSessionKey(legacyPicoSessionPrefix)
+ if strings.HasPrefix(base, legacyPrefix) {
+ sessionID := strings.TrimPrefix(base, legacyPrefix)
+ if sessionID == "" {
+ return picoJSONLSessionRef{}, false
+ }
+ return picoJSONLSessionRef{
+ ID: sessionID,
+ Key: legacyPicoSessionPrefix + sessionID,
+ }, true
+ }
+
+ if session.IsOpaqueSessionKey(base) {
+ return picoJSONLSessionRef{
+ ID: base,
+ Key: base,
+ }, true
+ }
+
+ return picoJSONLSessionRef{}, false
+}
+
+func (h *Handler) findLegacyPicoSession(dir, sessionID string) (picoLegacySessionRef, error) {
+ refs, err := h.findLegacyPicoSessions(dir)
+ if err != nil {
+ return picoLegacySessionRef{}, err
+ }
+ for _, ref := range refs {
+ if ref.ID == sessionID {
+ return ref, nil
+ }
+ }
+ return picoLegacySessionRef{}, os.ErrNotExist
+}
+
+func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArgsLength int) sessionListItem {
+ transcript := visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)
+
preview := ""
- for _, msg := range sess.Messages {
- if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
- preview = msg.Content
+ for _, msg := range transcript {
+ if msg.Role == "user" {
+ preview = sessionChatMessagePreview(msg)
+ }
+ if preview != "" {
break
}
}
- title := strings.TrimSpace(sess.Summary)
- if title == "" {
- title = preview
- }
-
- title = truncateRunes(title, maxSessionTitleRunes)
preview = truncateRunes(preview, maxSessionTitleRunes)
if preview == "" {
preview = "(empty)"
}
- if title == "" {
- title = preview
- }
-
- validMessageCount := 0
- for _, msg := range sess.Messages {
- if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
- validMessageCount++
- }
- }
+ title := preview
return sessionListItem{
ID: sessionID,
Title: title,
Preview: preview,
- MessageCount: validMessageCount,
+ MessageCount: len(transcript),
Created: sess.Created.Format(time.RFC3339),
Updated: sess.Updated.Format(time.RFC3339),
}
@@ -247,6 +455,306 @@ func truncateRunes(s string, maxLen int) string {
return string(runes[:maxLen]) + "..."
}
+func sessionChatMessageVisible(msg sessionChatMessage) bool {
+ return strings.TrimSpace(msg.Content) != "" ||
+ len(msg.Media) > 0 ||
+ len(msg.Attachments) > 0 ||
+ len(msg.ToolCalls) > 0
+}
+
+func sessionChatMessagePreview(msg sessionChatMessage) string {
+ if content := strings.TrimSpace(msg.Content); content != "" {
+ return content
+ }
+ if len(msg.Attachments) > 0 {
+ if strings.EqualFold(strings.TrimSpace(msg.Attachments[0].Type), "image") {
+ return "[image]"
+ }
+ return "[attachment]"
+ }
+ if len(msg.Media) > 0 {
+ if strings.HasPrefix(strings.TrimSpace(msg.Media[0]), "data:image/") {
+ return "[image]"
+ }
+ return "[attachment]"
+ }
+ if len(msg.ToolCalls) > 0 {
+ return "[tool call]"
+ }
+ return ""
+}
+
+func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage {
+ return sessionTranscriptMessages(messages, toolFeedbackMaxArgsLength, false)
+}
+
+func detailSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage {
+ return sessionTranscriptMessages(messages, toolFeedbackMaxArgsLength, true)
+}
+
+func sessionTranscriptMessages(
+ messages []providers.Message,
+ toolFeedbackMaxArgsLength int,
+ includeThoughts bool,
+) []sessionChatMessage {
+ transcript := make([]sessionChatMessage, 0, len(messages))
+
+ for _, msg := range messages {
+ attachments := sessionAttachments(msg)
+
+ switch msg.Role {
+ case "tool":
+ continue
+
+ case "user":
+ chatMsg := sessionChatMessage{
+ Role: "user",
+ Content: msg.Content,
+ Media: append([]string(nil), msg.Media...),
+ Attachments: attachments,
+ }
+ if sessionChatMessageVisible(chatMsg) {
+ transcript = append(transcript, chatMsg)
+ }
+
+ case "assistant":
+ if messageutil.IsTransientAssistantThoughtMessage(msg) {
+ continue
+ }
+ if includeThoughts {
+ if thoughtMsg, ok := assistantThoughtMessage(msg); ok {
+ transcript = append(transcript, thoughtMsg)
+ }
+ }
+
+ toolCallsMsg, hasToolCallsMsg := assistantToolCallsMessage(
+ msg.ToolCalls,
+ toolFeedbackMaxArgsLength,
+ )
+ visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls)
+
+ // Pico web chat can persist both visible `message` tool output and a
+ // later plain assistant reply in the same turn. Hide only the fixed
+ // internal summary that marks handled tool delivery.
+ content := msg.Content
+ if assistantMessageInternalOnly(msg) {
+ if len(attachments) == 0 {
+ if hasToolCallsMsg {
+ transcript = append(transcript, toolCallsMsg)
+ }
+ if len(visibleToolMessages) > 0 {
+ transcript = append(transcript, visibleToolMessages...)
+ }
+ continue
+ }
+ content = ""
+ }
+ if hasToolCallsMsg && utils.ToolCallExplanationDuplicatesContent(content, msg.ToolCalls) {
+ content = ""
+ }
+
+ chatMsg := sessionChatMessage{
+ Role: "assistant",
+ Content: content,
+ Media: append([]string(nil), msg.Media...),
+ Attachments: attachments,
+ }
+ if !sessionChatMessageVisible(chatMsg) {
+ if hasToolCallsMsg {
+ transcript = append(transcript, toolCallsMsg)
+ }
+ if len(visibleToolMessages) > 0 {
+ transcript = append(transcript, visibleToolMessages...)
+ }
+ continue
+ }
+
+ transcript = append(transcript, chatMsg)
+ if hasToolCallsMsg {
+ transcript = append(transcript, toolCallsMsg)
+ }
+ if len(visibleToolMessages) > 0 {
+ transcript = append(transcript, visibleToolMessages...)
+ }
+ }
+ }
+
+ return filterSessionChatMessages(transcript)
+}
+
+func filterSessionChatMessages(messages []sessionChatMessage) []sessionChatMessage {
+ filtered := messages[:0]
+ for _, msg := range messages {
+ if msg.Role != "user" && msg.Role != "assistant" {
+ continue
+ }
+ filtered = append(filtered, msg)
+ }
+ return filtered
+}
+
+func sessionAttachments(msg providers.Message) []sessionChatAttachment {
+ if len(msg.Attachments) == 0 {
+ return nil
+ }
+
+ attachments := make([]sessionChatAttachment, 0, len(msg.Attachments))
+ for _, attachment := range msg.Attachments {
+ urlValue, ok := sessionAttachmentURL(attachment)
+ if !ok {
+ continue
+ }
+ attachmentType := strings.TrimSpace(attachment.Type)
+ if attachmentType == "" {
+ attachmentType = sessionAttachmentType(attachment)
+ }
+ attachments = append(attachments, sessionChatAttachment{
+ Type: attachmentType,
+ URL: urlValue,
+ Filename: strings.TrimSpace(attachment.Filename),
+ ContentType: strings.TrimSpace(attachment.ContentType),
+ })
+ }
+
+ if len(attachments) == 0 {
+ return nil
+ }
+ return attachments
+}
+
+func sessionAttachmentURL(attachment providers.Attachment) (string, bool) {
+ if rawURL := strings.TrimSpace(attachment.URL); rawURL != "" {
+ return rawURL, true
+ }
+
+ ref := strings.TrimSpace(attachment.Ref)
+ if ref == "" {
+ return "", false
+ }
+ if strings.HasPrefix(ref, "media://") {
+ // Persisted session history must only expose durable attachment locations.
+ // media:// refs depend on the live in-memory MediaStore and may stop
+ // resolving after a restart or cleanup, so omit them from reopened history.
+ return "", false
+ }
+ return ref, true
+}
+
+func sessionAttachmentType(attachment providers.Attachment) string {
+ contentType := strings.ToLower(strings.TrimSpace(attachment.ContentType))
+ filename := strings.ToLower(strings.TrimSpace(attachment.Filename))
+ rawRef := strings.ToLower(strings.TrimSpace(attachment.Ref))
+ rawURL := strings.ToLower(strings.TrimSpace(attachment.URL))
+
+ switch {
+ case strings.HasPrefix(contentType, "image/"),
+ strings.HasPrefix(rawRef, "data:image/"),
+ strings.HasPrefix(rawURL, "data:image/"):
+ return "image"
+ case strings.HasPrefix(contentType, "audio/"):
+ return "audio"
+ case strings.HasPrefix(contentType, "video/"):
+ return "video"
+ }
+
+ switch ext := filepath.Ext(filename); ext {
+ case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
+ return "image"
+ case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
+ return "audio"
+ case ".mp4", ".avi", ".mov", ".webm", ".mkv":
+ return "video"
+ default:
+ return "file"
+ }
+}
+
+func assistantMessageInternalOnly(msg providers.Message) bool {
+ return strings.TrimSpace(msg.Content) == handledToolResponseSummaryText
+}
+
+func assistantThoughtMessage(msg providers.Message) (sessionChatMessage, bool) {
+ reasoning := strings.TrimSpace(msg.ReasoningContent)
+ if reasoning == "" {
+ return sessionChatMessage{}, false
+ }
+ if reasoning == strings.TrimSpace(msg.Content) {
+ return sessionChatMessage{}, false
+ }
+ return sessionChatMessage{
+ Role: "assistant",
+ Content: reasoning,
+ Kind: "thought",
+ }, true
+}
+
+func assistantToolCallsMessage(
+ toolCalls []providers.ToolCall,
+ toolFeedbackMaxArgsLength int,
+) (sessionChatMessage, bool) {
+ if len(toolCalls) == 0 {
+ return sessionChatMessage{}, false
+ }
+ if toolFeedbackMaxArgsLength <= 0 {
+ toolFeedbackMaxArgsLength = defaultToolFeedbackMaxArgsLength()
+ }
+
+ visibleToolCalls := utils.BuildVisibleToolCalls(toolCalls, toolFeedbackMaxArgsLength)
+ if len(visibleToolCalls) == 0 {
+ return sessionChatMessage{}, false
+ }
+
+ return sessionChatMessage{
+ Role: "assistant",
+ Kind: "tool_calls",
+ ToolCalls: visibleToolCalls,
+ }, true
+}
+
+func visibleAssistantToolArgsPreview(
+ tc providers.ToolCall,
+ toolFeedbackMaxArgsLength int,
+) string {
+ return utils.VisibleToolCallArgumentsPreview(tc, toolFeedbackMaxArgsLength)
+}
+
+func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage {
+ if len(toolCalls) == 0 {
+ return nil
+ }
+
+ messages := make([]sessionChatMessage, 0, len(toolCalls))
+ for _, tc := range toolCalls {
+ name, argsJSON := utils.VisibleToolCallNameAndArguments(tc)
+ if name != "message" {
+ continue
+ }
+ content, ok := parseMessageToolContent(argsJSON)
+ if !ok {
+ continue
+ }
+ messages = append(messages, sessionChatMessage{
+ Role: "assistant",
+ Content: content,
+ })
+ }
+
+ return messages
+}
+
+func parseMessageToolContent(argsJSON string) (string, bool) {
+ var args struct {
+ Content string `json:"content"`
+ }
+ if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
+ return "", false
+ }
+ if strings.TrimSpace(args.Content) == "" {
+ return "", false
+ }
+ return args.Content, true
+}
+
// sessionsDir resolves the path to the gateway's session storage directory.
// It reads the workspace from config, falling back to ~/.picoclaw/workspace.
func (h *Handler) sessionsDir() (string, error) {
@@ -255,7 +763,19 @@ func (h *Handler) sessionsDir() (string, error) {
return "", err
}
- workspace := cfg.Agents.Defaults.Workspace
+ return resolveSessionsDir(cfg.Agents.Defaults.Workspace), nil
+}
+
+func (h *Handler) sessionRuntimeSettings() (string, int, error) {
+ cfg, err := config.LoadConfig(h.configPath)
+ if err != nil {
+ return "", 0, err
+ }
+
+ return resolveSessionsDir(cfg.Agents.Defaults.Workspace), cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), nil
+}
+
+func resolveSessionsDir(workspace string) string {
if workspace == "" {
home, _ := os.UserHomeDir()
workspace = filepath.Join(home, ".picoclaw", "workspace")
@@ -271,21 +791,20 @@ func (h *Handler) sessionsDir() (string, error) {
}
}
- return filepath.Join(workspace, "sessions"), nil
+ return filepath.Join(workspace, "sessions")
}
// handleListSessions returns a list of Pico session summaries.
//
// GET /api/sessions
func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
- dir, err := h.sessionsDir()
+ dir, toolFeedbackMaxArgsLength, err := h.sessionRuntimeSettings()
if err != nil {
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
return
}
- entries, err := os.ReadDir(dir)
- if err != nil {
+ if _, err := os.ReadDir(dir); err != nil {
// Directory doesn't exist yet = no sessions
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]sessionListItem{})
@@ -295,74 +814,29 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
items := []sessionListItem{}
seen := make(map[string]struct{})
- for _, entry := range entries {
- if entry.IsDir() {
- continue
+ if refs, findErr := h.findPicoJSONLSessions(dir); findErr == nil {
+ for _, ref := range refs {
+ sess, loadErr := h.readJSONLSession(dir, ref.Key)
+ if loadErr != nil || isEmptySession(sess) {
+ continue
+ }
+ seen[ref.ID] = struct{}{}
+ items = append(items, buildSessionListItem(ref.ID, sess, toolFeedbackMaxArgsLength))
}
+ }
- name := entry.Name()
- var (
- sessionID string
- sess sessionFile
- loadErr error
- ok bool
- )
-
- switch {
- case strings.HasSuffix(name, ".jsonl"):
- sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl"))
- if !ok {
+ if legacyRefs, findErr := h.findLegacyPicoSessions(dir); findErr == nil {
+ for _, ref := range legacyRefs {
+ if _, exists := seen[ref.ID]; exists {
continue
}
- sess, loadErr = h.readJSONLSession(dir, sessionID)
- if loadErr == nil && isEmptySession(sess) {
+ sess, loadErr := h.readLegacySession(ref.Path)
+ if loadErr != nil || isEmptySession(sess) {
continue
}
- case strings.HasSuffix(name, ".meta.json"):
- continue
- case filepath.Ext(name) == ".json":
- base := strings.TrimSuffix(name, ".json")
- if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil {
- if jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found {
- if jsonlSess, jsonlErr := h.readJSONLSession(
- dir,
- jsonlSessionID,
- ); jsonlErr == nil &&
- !isEmptySession(jsonlSess) {
- continue
- }
- }
- }
- data, err := os.ReadFile(filepath.Join(dir, name))
- if err != nil {
- continue
- }
- if err := json.Unmarshal(data, &sess); err != nil {
- continue
- }
- if isEmptySession(sess) {
- continue
- }
- sessionID, ok = extractPicoSessionID(sess.Key)
- if !ok {
- continue
- }
- if _, exists := seen[sessionID]; exists {
- continue
- }
- default:
- continue
+ seen[ref.ID] = struct{}{}
+ items = append(items, buildSessionListItem(ref.ID, sess, toolFeedbackMaxArgsLength))
}
-
- if loadErr != nil {
- continue
- }
- if _, exists := seen[sessionID]; exists {
- continue
- }
-
- seen[sessionID] = struct{}{}
- items = append(items, buildSessionListItem(sessionID, sess))
}
// Sort by updated descending (most recent first)
@@ -410,19 +884,26 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
return
}
- dir, err := h.sessionsDir()
+ dir, toolFeedbackMaxArgsLength, err := h.sessionRuntimeSettings()
if err != nil {
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
return
}
- sess, err := h.readJSONLSession(dir, sessionID)
+ ref, refErr := h.findPicoJSONLSession(dir, sessionID)
+ var sess sessionFile
+ err = refErr
+ if refErr == nil {
+ sess, err = h.readJSONLSession(dir, ref.Key)
+ }
if err == nil && isEmptySession(sess) {
err = os.ErrNotExist
}
if err != nil {
if errors.Is(err, os.ErrNotExist) {
- sess, err = h.readLegacySession(dir, sessionID)
+ if legacyRef, legacyErr := h.findLegacyPicoSession(dir, sessionID); legacyErr == nil {
+ sess, err = h.readLegacySession(legacyRef.Path)
+ }
if err == nil && isEmptySession(sess) {
err = os.ErrNotExist
}
@@ -437,22 +918,7 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
}
}
- // Convert to a simpler format for the frontend
- type chatMessage struct {
- Role string `json:"role"`
- Content string `json:"content"`
- }
-
- messages := make([]chatMessage, 0, len(sess.Messages))
- for _, msg := range sess.Messages {
- // Only include user and assistant messages that have actual content
- if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
- messages = append(messages, chatMessage{
- Role: msg.Role,
- Content: msg.Content,
- })
- }
- }
+ messages := detailSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
@@ -480,21 +946,30 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
return
}
- base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
- jsonlPath := base + ".jsonl"
- metaPath := base + ".meta.json"
- legacyPath := base + ".json"
-
removed := false
- for _, path := range []string{jsonlPath, metaPath, legacyPath} {
- if err := os.Remove(path); err != nil {
- if os.IsNotExist(err) {
- continue
+ if ref, err := h.findPicoJSONLSession(dir, sessionID); err == nil {
+ base := filepath.Join(dir, sanitizeSessionKey(ref.Key))
+ for _, path := range []string{base + ".jsonl", base + ".meta.json"} {
+ if err := os.Remove(path); err != nil {
+ if os.IsNotExist(err) {
+ continue
+ }
+ http.Error(w, "failed to delete session", http.StatusInternalServerError)
+ return
}
- http.Error(w, "failed to delete session", http.StatusInternalServerError)
- return
+ removed = true
+ }
+ }
+
+ if legacyRef, err := h.findLegacyPicoSession(dir, sessionID); err == nil {
+ if err := os.Remove(legacyRef.Path); err != nil {
+ if !os.IsNotExist(err) {
+ http.Error(w, "failed to delete session", http.StatusInternalServerError)
+ return
+ }
+ } else {
+ removed = true
}
- removed = true
}
if !removed {
diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go
index 21ef5b5b8..760935db7 100644
--- a/web/backend/api/session_test.go
+++ b/web/backend/api/session_test.go
@@ -6,12 +6,15 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "strings"
"testing"
+ "time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session"
+ "github.com/sipeed/picoclaw/pkg/utils"
)
func sessionsTestDir(t *testing.T, configPath string) string {
@@ -29,17 +32,36 @@ func sessionsTestDir(t *testing.T, configPath string) string {
return dir
}
+func assertVisibleToolCallMessage(
+ t *testing.T,
+ msg sessionChatMessage,
+ toolName string,
+) utils.VisibleToolCall {
+ t.Helper()
+
+ if msg.Role != "assistant" || msg.Kind != "tool_calls" {
+ t.Fatalf("message = %#v, want assistant/tool_calls", msg)
+ }
+ if len(msg.ToolCalls) != 1 {
+ t.Fatalf("len(message.ToolCalls) = %d, want 1", len(msg.ToolCalls))
+ }
+ if got := msg.ToolCalls[0].Function; got == nil || got.Name != toolName {
+ t.Fatalf("tool call = %#v, want function %q", msg.ToolCalls[0], toolName)
+ }
+ return msg.ToolCalls[0]
+}
+
func TestHandleListSessions_JSONLStorage(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
- store, err := memory.NewJSONLStore(dir)
- if err != nil {
- t.Fatalf("NewJSONLStore() error = %v", err)
+ store, storeErr := memory.NewJSONLStore(dir)
+ if storeErr != nil {
+ t.Fatalf("NewJSONLStore() error = %v", storeErr)
}
- sessionKey := picoSessionPrefix + "history-jsonl"
+ sessionKey := legacyPicoSessionPrefix + "history-jsonl"
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
Role: "user",
Content: "Explain why the history API is empty after migration.",
@@ -87,25 +109,87 @@ func TestHandleListSessions_JSONLStorage(t *testing.T) {
if items[0].MessageCount != 2 {
t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount)
}
- if items[0].Title != "JSONL-backed session" {
- t.Fatalf("items[0].Title = %q, want %q", items[0].Title, "JSONL-backed session")
+ if items[0].Title != "Explain why the history API is empty after migration." {
+ t.Fatalf(
+ "items[0].Title = %q, want %q",
+ items[0].Title,
+ "Explain why the history API is empty after migration.",
+ )
}
if items[0].Preview != "Explain why the history API is empty after migration." {
t.Fatalf("items[0].Preview = %q", items[0].Preview)
}
}
-func TestHandleListSessions_TitleUsesTrimmedSummary(t *testing.T) {
+func TestHandleListSessions_TransientThoughtDoesNotInflateMessageCount(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
- store, err := memory.NewJSONLStore(dir)
+ sessionKey := legacyPicoSessionPrefix + "history-jsonl-transient"
+ base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
+ now := time.Now().UTC()
+
+ rawJSONL := strings.Join([]string{
+ `{"role":"user","content":"keep me"}`,
+ `{"role":"assistant","content":"","reasoning_content":"dangling thought"}`,
+ `{"role":"assistant","content":"and me"}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(base+".jsonl", []byte(rawJSONL), 0o644); err != nil {
+ t.Fatalf("WriteFile(jsonl) error = %v", err)
+ }
+ metaData, err := json.Marshal(memory.SessionMeta{
+ Key: sessionKey,
+ Count: 3,
+ Skip: 0,
+ CreatedAt: now,
+ UpdatedAt: now,
+ })
if err != nil {
- t.Fatalf("NewJSONLStore() error = %v", err)
+ t.Fatalf("Marshal(meta) error = %v", err)
+ }
+ if err := os.WriteFile(base+".meta.json", metaData, 0o644); err != nil {
+ t.Fatalf("WriteFile(meta) error = %v", err)
}
- sessionKey := picoSessionPrefix + "summary-title"
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var items []sessionListItem
+ if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("len(items) = %d, want 1", len(items))
+ }
+ if items[0].ID != "history-jsonl-transient" {
+ t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "history-jsonl-transient")
+ }
+ if items[0].MessageCount != 2 {
+ t.Fatalf("items[0].MessageCount = %d, want 2 after dropping transient thought", items[0].MessageCount)
+ }
+}
+
+func TestHandleListSessions_TitleUsesFirstUserMessage(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, storeErr := memory.NewJSONLStore(dir)
+ if storeErr != nil {
+ t.Fatalf("NewJSONLStore() error = %v", storeErr)
+ }
+
+ sessionKey := legacyPicoSessionPrefix + "summary-title"
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
Role: "user",
Content: "fallback preview",
@@ -139,10 +223,7 @@ func TestHandleListSessions_TitleUsesTrimmedSummary(t *testing.T) {
if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items))
}
- expectedTitle := truncateRunes(
- "This summary is intentionally longer than sixty characters so it must be truncated in the history menu.",
- maxSessionTitleRunes,
- )
+ expectedTitle := truncateRunes("fallback preview", maxSessionTitleRunes)
if items[0].Title != expectedTitle {
t.Fatalf("items[0].Title = %q", items[0].Title)
}
@@ -161,7 +242,7 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) {
t.Fatalf("NewJSONLStore() error = %v", err)
}
- sessionKey := picoSessionPrefix + "detail-jsonl"
+ sessionKey := legacyPicoSessionPrefix + "detail-jsonl"
for _, msg := range []providers.Message{
{Role: "user", Content: "first"},
{Role: "assistant", Content: "second"},
@@ -215,6 +296,1275 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) {
}
}
+func TestHandleGetSession_HidesHandledToolAttachmentsBackedByMediaRefs(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := legacyPicoSessionPrefix + "attachment-history"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "send me the report"},
+ {
+ Role: "assistant",
+ Content: handledToolResponseSummaryText,
+ Attachments: []providers.Attachment{{
+ Type: "file",
+ Ref: "media://attachment-1",
+ Filename: "report.txt",
+ ContentType: "text/plain",
+ }},
+ },
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/attachment-history", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ if len(resp.Messages) != 1 {
+ t.Fatalf("len(resp.Messages) = %d, want 1", len(resp.Messages))
+ }
+ if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "send me the report" {
+ t.Fatalf("message = %#v, want only user request", resp.Messages[0])
+ }
+}
+
+func TestHandleGetSession_ExposesHandledToolAttachmentsWithDurableURL(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := legacyPicoSessionPrefix + "attachment-history-durable"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "send me the report"},
+ {
+ Role: "assistant",
+ Content: handledToolResponseSummaryText,
+ Attachments: []providers.Attachment{{
+ Type: "file",
+ URL: "https://example.com/report.txt",
+ Filename: "report.txt",
+ ContentType: "text/plain",
+ }},
+ },
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/attachment-history-durable", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ if len(resp.Messages) != 2 {
+ t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
+ }
+
+ assistant := resp.Messages[1]
+ if assistant.Role != "assistant" {
+ t.Fatalf("assistant role = %q, want assistant", assistant.Role)
+ }
+ if assistant.Content != "" {
+ t.Fatalf("assistant content = %q, want empty string", assistant.Content)
+ }
+ if len(assistant.Attachments) != 1 {
+ t.Fatalf("len(assistant.Attachments) = %d, want 1", len(assistant.Attachments))
+ }
+ if assistant.Attachments[0].URL != "https://example.com/report.txt" {
+ t.Fatalf(
+ "attachment url = %q, want %q",
+ assistant.Attachments[0].URL,
+ "https://example.com/report.txt",
+ )
+ }
+ if assistant.Attachments[0].Filename != "report.txt" {
+ t.Fatalf("attachment filename = %q, want %q", assistant.Attachments[0].Filename, "report.txt")
+ }
+}
+
+func TestHandleSessions_JSONLScopeDiscovery(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, storeErr := memory.NewJSONLStore(dir)
+ if storeErr != nil {
+ t.Fatalf("NewJSONLStore() error = %v", storeErr)
+ }
+
+ sessionKey := "sk_v1_scope_discovery"
+ if err := store.AddFullMessage(nil, sessionKey, providers.Message{
+ Role: "user",
+ Content: "scope discovered session",
+ }); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ if err := store.SetSummary(nil, sessionKey, "scope summary"); err != nil {
+ t.Fatalf("SetSummary() error = %v", err)
+ }
+
+ scopeData, err := json.Marshal(session.SessionScope{
+ Version: session.ScopeVersionV1,
+ AgentID: "main",
+ Channel: "pico",
+ Account: "default",
+ Dimensions: []string{"sender"},
+ Values: map[string]string{
+ "sender": "pico:scope-jsonl",
+ },
+ })
+ if err != nil {
+ t.Fatalf("Marshal(scope) error = %v", err)
+ }
+ if err := store.UpsertSessionMeta(nil, sessionKey, scopeData, nil); err != nil {
+ t.Fatalf("UpsertSessionMeta() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ listRec := httptest.NewRecorder()
+ listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
+ mux.ServeHTTP(listRec, listReq)
+ if listRec.Code != http.StatusOK {
+ t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String())
+ }
+
+ var items []sessionListItem
+ if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil {
+ t.Fatalf("Unmarshal(list) error = %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("len(items) = %d, want 1", len(items))
+ }
+ if items[0].ID != "scope-jsonl" {
+ t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "scope-jsonl")
+ }
+
+ detailRec := httptest.NewRecorder()
+ detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/scope-jsonl", nil)
+ mux.ServeHTTP(detailRec, detailReq)
+ if detailRec.Code != http.StatusOK {
+ t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusOK, detailRec.Body.String())
+ }
+
+ deleteRec := httptest.NewRecorder()
+ deleteReq := httptest.NewRequest(http.MethodDelete, "/api/sessions/scope-jsonl", nil)
+ mux.ServeHTTP(deleteRec, deleteReq)
+ if deleteRec.Code != http.StatusNoContent {
+ t.Fatalf("delete status = %d, want %d, body=%s", deleteRec.Code, http.StatusNoContent, deleteRec.Body.String())
+ }
+}
+
+func TestHandleGetSession_SkipsTransientThoughtMessages(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-transient-thought"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "hello"},
+ {Role: "assistant", ReasoningContent: "internal chain of thought"},
+ {Role: "assistant", Content: "final visible answer"},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-transient-thought", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 2 {
+ t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
+ }
+ if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "hello" {
+ t.Fatalf("first message = %#v, want user/hello", resp.Messages[0])
+ }
+ if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "final visible answer" {
+ t.Fatalf("second message = %#v, want assistant/final visible answer", resp.Messages[1])
+ }
+}
+
+func TestHandleGetSession_ReconstructsThoughtFromAssistantReasoningContent(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-reasoning-content"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "hello"},
+ {Role: "assistant", Content: "final visible answer", ReasoningContent: "internal chain of thought"},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-reasoning-content", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 3 {
+ t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
+ }
+ if resp.Messages[1].Role != "assistant" ||
+ resp.Messages[1].Content != "internal chain of thought" ||
+ resp.Messages[1].Kind != "thought" {
+ t.Fatalf("thought message = %#v, want assistant thought/internal chain of thought", resp.Messages[1])
+ }
+ if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final visible answer" {
+ t.Fatalf("final message = %#v, want assistant/final visible answer", resp.Messages[2])
+ }
+}
+
+func TestHandleGetSession_ReconstructsRefreshMatrixForThoughtAndToolSummary(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-refresh-matrix"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "turn1"},
+ {Role: "assistant", Content: "plain visible", ReasoningContent: "plain thought"},
+ {Role: "user", Content: "turn2"},
+ {
+ Role: "assistant",
+ ReasoningContent: "tool thought",
+ ToolCalls: []providers.ToolCall{{
+ ID: "call_read_file",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: `{"path":"README.md"}`,
+ },
+ }},
+ },
+ {Role: "tool", ToolCallID: "call_read_file", Content: "file result"},
+ {Role: "user", Content: "turn3"},
+ {
+ Role: "assistant",
+ Content: "tool visible only",
+ ToolCalls: []providers.ToolCall{{
+ ID: "call_list_dir",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "list_dir",
+ Arguments: `{"path":"."}`,
+ },
+ }},
+ },
+ {Role: "tool", ToolCallID: "call_list_dir", Content: "dir result"},
+ {Role: "user", Content: "turn4"},
+ {
+ Role: "assistant",
+ Content: "tool visible and thought",
+ ReasoningContent: "tool mixed thought",
+ ToolCalls: []providers.ToolCall{{
+ ID: "call_exec",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "exec",
+ Arguments: `{"command":"pwd"}`,
+ },
+ }},
+ },
+ {Role: "tool", ToolCallID: "call_exec", Content: "pwd result"},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-refresh-matrix", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ if len(resp.Messages) != 13 {
+ t.Fatalf("len(resp.Messages) = %d, want 13", len(resp.Messages))
+ }
+
+ assertMessage := func(index int, role, kind, content string) {
+ t.Helper()
+ msg := resp.Messages[index]
+ if msg.Role != role || msg.Kind != kind || msg.Content != content {
+ t.Fatalf("messages[%d] = %#v, want role=%q kind=%q content=%q", index, msg, role, kind, content)
+ }
+ }
+
+ assertMessage(0, "user", "", "turn1")
+ assertMessage(1, "assistant", "thought", "plain thought")
+ assertMessage(2, "assistant", "", "plain visible")
+ assertMessage(3, "user", "", "turn2")
+ assertMessage(4, "assistant", "thought", "tool thought")
+ assertVisibleToolCallMessage(t, resp.Messages[5], "read_file")
+ assertMessage(6, "user", "", "turn3")
+ assertMessage(7, "assistant", "", "tool visible only")
+ assertVisibleToolCallMessage(t, resp.Messages[8], "list_dir")
+ assertMessage(9, "user", "", "turn4")
+ assertMessage(10, "assistant", "thought", "tool mixed thought")
+ assertMessage(11, "assistant", "", "tool visible and thought")
+ assertVisibleToolCallMessage(t, resp.Messages[12], "exec")
+}
+
+func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSummary(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-message-tool"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "test"},
+ {
+ Role: "assistant",
+ Content: "",
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "message",
+ Arguments: `{"content":"visible tool output"}`,
+ },
+ },
+ },
+ },
+ {Role: "tool", Content: "Message sent to pico:pico:detail-message-tool", ToolCallID: "call_1"},
+ {Role: "assistant", Content: handledToolResponseSummaryText},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-message-tool", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 3 {
+ t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
+ }
+ if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" {
+ t.Fatalf("first message = %#v, want user/test", resp.Messages[0])
+ }
+ assertVisibleToolCallMessage(t, resp.Messages[1], "message")
+ if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" {
+ t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[2])
+ }
+}
+
+func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-message-tool-final-reply"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "test"},
+ {
+ Role: "assistant",
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "message",
+ Arguments: `{"content":"visible tool output"}`,
+ },
+ },
+ },
+ },
+ {Role: "tool", Content: "Message sent to pico:pico:detail-message-tool-final-reply", ToolCallID: "call_1"},
+ {Role: "assistant", Content: "final assistant reply"},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-message-tool-final-reply", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 4 {
+ t.Fatalf("len(resp.Messages) = %d, want 4", len(resp.Messages))
+ }
+ if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" {
+ t.Fatalf("first message = %#v, want user/test", resp.Messages[0])
+ }
+ assertVisibleToolCallMessage(t, resp.Messages[1], "message")
+ if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" {
+ t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[2])
+ }
+ if resp.Messages[3].Role != "assistant" || resp.Messages[3].Content != "final assistant reply" {
+ t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[3])
+ }
+}
+
+func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "list-visible-count"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "test"},
+ {
+ Role: "assistant",
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "message",
+ Arguments: `{"content":"visible tool output"}`,
+ },
+ },
+ },
+ },
+ {Role: "tool", Content: "Message sent to pico:pico:list-visible-count", ToolCallID: "call_1"},
+ {Role: "assistant", Content: handledToolResponseSummaryText},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var items []sessionListItem
+ if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("len(items) = %d, want 1", len(items))
+ }
+ if items[0].MessageCount != 3 {
+ t.Fatalf("items[0].MessageCount = %d, want 3", items[0].MessageCount)
+ }
+}
+
+func TestHandleListSessions_DeduplicatesAssistantToolCallContentFromVisibleTranscript(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "list-deduped-tool-content"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "check file"},
+ {
+ Role: "assistant",
+ Content: "Read the file before replying.",
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: `{"path":"README.md"}`,
+ },
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: "Read the file before replying.",
+ },
+ },
+ },
+ },
+ {Role: "tool", Content: "raw read_file result", ToolCallID: "call_1"},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var items []sessionListItem
+ if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("len(items) = %d, want 1", len(items))
+ }
+ if items[0].MessageCount != 2 {
+ t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount)
+ }
+}
+
+func TestHandleGetSession_DoesNotDuplicateAssistantToolCallContent(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-tool-summary-and-content"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "check file"},
+ {
+ Role: "assistant",
+ Content: "Read the file before replying.",
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: `{"path":"README.md","start_line":1,"end_line":10}`,
+ },
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: "Read the file before replying.",
+ },
+ },
+ },
+ },
+ {Role: "tool", Content: "raw read_file result", ToolCallID: "call_1"},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-and-content", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 2 {
+ t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
+ }
+ if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" {
+ t.Fatalf("first message = %#v, want user/check file", resp.Messages[0])
+ }
+ toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file")
+ if toolCall.ExtraContent == nil ||
+ toolCall.ExtraContent.ToolFeedbackExplanation != "Read the file before replying." {
+ t.Fatalf("tool call = %#v, want explanation", toolCall)
+ }
+}
+
+func TestHandleGetSession_PreservesDistinctAssistantToolCallContent(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-tool-summary-distinct-content"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "check file"},
+ {
+ Role: "assistant",
+ Content: "I will summarize the findings after reading the file.",
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: `{"path":"README.md","start_line":1,"end_line":10}`,
+ },
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: "Read the file before replying.",
+ },
+ },
+ },
+ },
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-distinct-content", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 3 {
+ t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
+ }
+ if resp.Messages[1].Role != "assistant" ||
+ resp.Messages[1].Content != "I will summarize the findings after reading the file." {
+ t.Fatalf("assistant content = %#v, want preserved distinct content", resp.Messages[1])
+ }
+ assertVisibleToolCallMessage(t, resp.Messages[2], "read_file")
+}
+
+func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-tool-summary-duplicate-content-with-media"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "check screenshot"},
+ {
+ Role: "assistant",
+ Content: "Reviewing the generated screenshot.",
+ Media: []string{"data:image/png;base64,abc123"},
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "view_image",
+ Arguments: `{"path":"artifact.png"}`,
+ },
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: "Reviewing the generated screenshot.",
+ },
+ },
+ },
+ },
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-duplicate-content-with-media", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 3 {
+ t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
+ }
+ if resp.Messages[1].Role != "assistant" {
+ t.Fatalf("assistant message role = %q, want assistant", resp.Messages[1].Role)
+ }
+ if resp.Messages[1].Content != "" {
+ t.Fatalf("assistant content = %q, want duplicate content suppressed", resp.Messages[1].Content)
+ }
+ if len(resp.Messages[1].Media) != 1 || resp.Messages[1].Media[0] != "data:image/png;base64,abc123" {
+ t.Fatalf("assistant media = %#v, want preserved media", resp.Messages[1].Media)
+ }
+ assertVisibleToolCallMessage(t, resp.Messages[2], "view_image")
+}
+
+func TestHandleGetSession_PreservesAttachmentsWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-tool-summary-duplicate-content-with-attachments"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "check report"},
+ {
+ Role: "assistant",
+ Content: "Reviewing the generated report.",
+ Attachments: []providers.Attachment{{
+ Type: "file",
+ URL: "https://example.com/report.txt",
+ Filename: "report.txt",
+ ContentType: "text/plain",
+ }},
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: `{"path":"report.txt"}`,
+ },
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: "Reviewing the generated report.",
+ },
+ },
+ },
+ },
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/api/sessions/detail-tool-summary-duplicate-content-with-attachments",
+ nil,
+ )
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 3 {
+ t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
+ }
+ if resp.Messages[1].Role != "assistant" {
+ t.Fatalf("assistant message role = %q, want assistant", resp.Messages[1].Role)
+ }
+ if resp.Messages[1].Content != "" {
+ t.Fatalf("assistant content = %q, want duplicate content suppressed", resp.Messages[1].Content)
+ }
+ if len(resp.Messages[1].Attachments) != 1 {
+ t.Fatalf("len(assistant.Attachments) = %d, want 1", len(resp.Messages[1].Attachments))
+ }
+ if resp.Messages[1].Attachments[0].URL != "https://example.com/report.txt" {
+ t.Fatalf("attachment url = %q, want report URL", resp.Messages[1].Attachments[0].URL)
+ }
+ assertVisibleToolCallMessage(t, resp.Messages[2], "read_file")
+}
+
+func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.Agents.Defaults.ToolFeedback.MaxArgsLength = 20
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}`
+ explanation := "Read README.md first to confirm the current project structure before editing the config example."
+ sessionKey := picoSessionPrefix + "detail-tool-summary-max-args"
+ err = store.AddFullMessage(nil, sessionKey, providers.Message{Role: "user", Content: "check file"})
+ if err != nil {
+ t.Fatalf("AddFullMessage(user) error = %v", err)
+ }
+ err = store.AddFullMessage(nil, sessionKey, providers.Message{
+ Role: "assistant",
+ ToolCalls: []providers.ToolCall{{
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: argsJSON,
+ },
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: explanation,
+ },
+ }},
+ })
+ if err != nil {
+ t.Fatalf("AddFullMessage(assistant) error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-max-args", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ err = json.Unmarshal(rec.Body.Bytes(), &resp)
+ if err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) < 2 {
+ t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages))
+ }
+
+ wantArgsPreview := visibleAssistantToolArgsPreview(providers.ToolCall{
+ Function: &providers.FunctionCall{Arguments: argsJSON},
+ }, 20)
+ toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file")
+ if toolCall.ExtraContent == nil || toolCall.ExtraContent.ToolFeedbackExplanation != explanation {
+ t.Fatalf("tool call = %#v, want full explanation %q", toolCall, explanation)
+ }
+ if toolCall.Function == nil || toolCall.Function.Arguments != wantArgsPreview {
+ t.Fatalf("tool call = %#v, want args preview %q", toolCall, wantArgsPreview)
+ }
+}
+
+func TestHandleGetSession_FallsBackToLegacyToolArgumentsWhenExplanationMissing(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.Agents.Defaults.ToolFeedback.MaxArgsLength = 20
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}`
+ sessionKey := picoSessionPrefix + "detail-tool-summary-legacy-args"
+ if err := store.AddFullMessage(
+ nil,
+ sessionKey,
+ providers.Message{Role: "user", Content: "check file"},
+ ); err != nil {
+ t.Fatalf("AddFullMessage(user) error = %v", err)
+ }
+ if err := store.AddFullMessage(nil, sessionKey, providers.Message{
+ Role: "assistant",
+ ToolCalls: []providers.ToolCall{{
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: argsJSON,
+ },
+ }},
+ }); err != nil {
+ t.Fatalf("AddFullMessage(assistant) error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-legacy-args", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) < 2 {
+ t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages))
+ }
+
+ wantPreview := visibleAssistantToolArgsPreview(providers.ToolCall{
+ Function: &providers.FunctionCall{Arguments: argsJSON},
+ }, 20)
+ toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file")
+ if toolCall.Function == nil || toolCall.Function.Arguments != wantPreview {
+ t.Fatalf("tool call = %#v, want legacy args preview %q", toolCall, wantPreview)
+ }
+}
+
+func TestHandleGetSession_IncludesMediaOnlyMessages(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-media-only"
+ if err := store.AddFullMessage(nil, sessionKey, providers.Message{
+ Role: "user",
+ Media: []string{"data:image/png;base64,abc123"},
+ }); err != nil {
+ t.Fatalf("AddFullMessage(user) error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-media-only", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ Media []string `json:"media"`
+ } `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 1 {
+ t.Fatalf("len(resp.Messages) = %d, want 1", len(resp.Messages))
+ }
+ if resp.Messages[0].Role != "user" || len(resp.Messages[0].Media) != 1 {
+ t.Fatalf("message = %#v, want user message with media", resp.Messages[0])
+ }
+}
+
+func TestHandleSessions_SupportsJSONLMessagesUpToStoreCap(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-large-jsonl"
+ largeContent := strings.Repeat("x", 9*1024*1024)
+ if err := store.AddFullMessage(nil, sessionKey, providers.Message{
+ Role: "user",
+ Content: largeContent,
+ }); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ listRec := httptest.NewRecorder()
+ listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
+ mux.ServeHTTP(listRec, listReq)
+
+ if listRec.Code != http.StatusOK {
+ t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String())
+ }
+
+ var items []sessionListItem
+ if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil {
+ t.Fatalf("list Unmarshal() error = %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("len(items) = %d, want 1", len(items))
+ }
+
+ detailRec := httptest.NewRecorder()
+ detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-large-jsonl", nil)
+ mux.ServeHTTP(detailRec, detailReq)
+
+ if detailRec.Code != http.StatusOK {
+ t.Fatalf(
+ "detail status = %d, want %d, body=%s",
+ detailRec.Code,
+ http.StatusOK,
+ detailRec.Body.String(),
+ )
+ }
+
+ var resp struct {
+ Messages []struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"messages"`
+ }
+ if err := json.Unmarshal(detailRec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("detail Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 1 {
+ t.Fatalf("len(resp.Messages) = %d, want 1", len(resp.Messages))
+ }
+ if resp.Messages[0].Role != "user" {
+ t.Fatalf("resp.Messages[0].Role = %q, want %q", resp.Messages[0].Role, "user")
+ }
+ if got := len(resp.Messages[0].Content); got != len(largeContent) {
+ t.Fatalf("len(resp.Messages[0].Content) = %d, want %d", got, len(largeContent))
+ }
+}
+
+func TestHandleListSessions_UsesImagePreviewForMediaOnlyMessage(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "preview-media-only"
+ if err := store.AddFullMessage(nil, sessionKey, providers.Message{
+ Role: "user",
+ Media: []string{"data:image/png;base64,abc123"},
+ }); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var items []sessionListItem
+ if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("len(items) = %d, want 1", len(items))
+ }
+ if items[0].Preview != "[image]" {
+ t.Fatalf("items[0].Preview = %q, want %q", items[0].Preview, "[image]")
+ }
+ if items[0].MessageCount != 1 {
+ t.Fatalf("items[0].MessageCount = %d, want 1", items[0].MessageCount)
+ }
+}
+
func TestHandleDeleteSession_JSONLStorage(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -225,7 +1575,7 @@ func TestHandleDeleteSession_JSONLStorage(t *testing.T) {
t.Fatalf("NewJSONLStore() error = %v", err)
}
- sessionKey := picoSessionPrefix + "delete-jsonl"
+ sessionKey := legacyPicoSessionPrefix + "delete-jsonl"
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
Role: "user",
Content: "delete me",
@@ -262,7 +1612,7 @@ func TestHandleGetSession_LegacyJSONFallback(t *testing.T) {
dir := sessionsTestDir(t, configPath)
manager := session.NewSessionManager(dir)
- sessionKey := picoSessionPrefix + "legacy-json"
+ sessionKey := legacyPicoSessionPrefix + "legacy-json"
manager.AddMessage(sessionKey, "user", "legacy user")
manager.AddMessage(sessionKey, "assistant", "legacy assistant")
if err := manager.Save(sessionKey); err != nil {
@@ -287,7 +1637,7 @@ func TestHandleSessions_FiltersEmptyJSONLFiles(t *testing.T) {
defer cleanup()
dir := sessionsTestDir(t, configPath)
- base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+"empty-jsonl"))
+ base := filepath.Join(dir, sanitizeSessionKey(legacyPicoSessionPrefix+"empty-jsonl"))
if err := os.WriteFile(base+".jsonl", []byte{}, 0o644); err != nil {
t.Fatalf("WriteFile(jsonl) error = %v", err)
}
@@ -320,3 +1670,82 @@ func TestHandleSessions_FiltersEmptyJSONLFiles(t *testing.T) {
t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusNotFound, detailRec.Body.String())
}
}
+
+func TestHandleSessions_ListsLegacyJSONLWithoutMeta(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ sessionKey := legacyPicoSessionPrefix + "missing-meta"
+ base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
+ line, err := json.Marshal(providers.Message{Role: "user", Content: "recover me"})
+ if err != nil {
+ t.Fatalf("Marshal(message) error = %v", err)
+ }
+ if err := os.WriteFile(base+".jsonl", append(line, '\n'), 0o644); err != nil {
+ t.Fatalf("WriteFile(jsonl) error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ listRec := httptest.NewRecorder()
+ listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
+ mux.ServeHTTP(listRec, listReq)
+
+ if listRec.Code != http.StatusOK {
+ t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String())
+ }
+
+ var items []sessionListItem
+ if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil {
+ t.Fatalf("Unmarshal(list) error = %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("len(items) = %d, want 1", len(items))
+ }
+ if items[0].ID != "missing-meta" {
+ t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "missing-meta")
+ }
+
+ detailRec := httptest.NewRecorder()
+ detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/missing-meta", nil)
+ mux.ServeHTTP(detailRec, detailReq)
+
+ if detailRec.Code != http.StatusOK {
+ t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusOK, detailRec.Body.String())
+ }
+}
+
+func TestHandleSessions_IgnoresMetaJSONInLegacyFallback(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ metaOnly := filepath.Join(dir, "agent_main_pico_direct_pico_meta-only.meta.json")
+ metaOnlyContent := []byte(`{"key":"agent:main:pico:direct:pico:meta-only","summary":"meta only"}`)
+ if err := os.WriteFile(metaOnly, metaOnlyContent, 0o644); err != nil {
+ t.Fatalf("WriteFile(meta) error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ listRec := httptest.NewRecorder()
+ listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
+ mux.ServeHTTP(listRec, listReq)
+
+ if listRec.Code != http.StatusOK {
+ t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String())
+ }
+
+ var items []sessionListItem
+ if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil {
+ t.Fatalf("Unmarshal(list) error = %v", err)
+ }
+ if len(items) != 0 {
+ t.Fatalf("len(items) = %d, want 0", len(items))
+ }
+}
diff --git a/web/backend/api/skills.go b/web/backend/api/skills.go
index 3c2fb57dd..e89ff7c30 100644
--- a/web/backend/api/skills.go
+++ b/web/backend/api/skills.go
@@ -1,40 +1,116 @@
package api
import (
+ "bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
+ "io/fs"
"net/http"
"os"
"path/filepath"
"regexp"
+ "strconv"
"strings"
+ "sync"
+ "time"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/skills"
+ "github.com/sipeed/picoclaw/pkg/utils"
)
+const defaultInstallSkillRegistry = "github"
+
type skillSupportResponse struct {
- Skills []skills.SkillInfo `json:"skills"`
+ Skills []skillSupportItem `json:"skills"`
+}
+
+type skillSupportItem struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Source string `json:"source"`
+ Description string `json:"description"`
+ OriginKind string `json:"origin_kind"`
+ RegistryName string `json:"registry_name,omitempty"`
+ RegistryURL string `json:"registry_url,omitempty"`
+ InstalledVersion string `json:"installed_version,omitempty"`
+ InstalledAt int64 `json:"installed_at,omitempty"`
}
type skillDetailResponse struct {
- Name string `json:"name"`
- Path string `json:"path"`
- Source string `json:"source"`
- Description string `json:"description"`
- Content string `json:"content"`
+ skillSupportItem
+ Content string `json:"content"`
+}
+
+type skillSearchResultItem struct {
+ Score float64 `json:"score"`
+ Slug string `json:"slug"`
+ DisplayName string `json:"display_name"`
+ Summary string `json:"summary"`
+ Version string `json:"version"`
+ RegistryName string `json:"registry_name"`
+ URL string `json:"url,omitempty"`
+ Installed bool `json:"installed"`
+ InstalledName string `json:"installed_name,omitempty"`
+}
+
+type skillSearchResponse struct {
+ Results []skillSearchResultItem `json:"results"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+ NextOffset int `json:"next_offset,omitempty"`
+ HasMore bool `json:"has_more"`
+}
+
+type installSkillRequest struct {
+ Slug string `json:"slug"`
+ Registry string `json:"registry"`
+ Version string `json:"version,omitempty"`
+ Force bool `json:"force,omitempty"`
+}
+
+type installSkillResponse struct {
+ Status string `json:"status"`
+ Slug string `json:"slug"`
+ Registry string `json:"registry"`
+ Version string `json:"version"`
+ Summary string `json:"summary,omitempty"`
+ IsSuspicious bool `json:"is_suspicious,omitempty"`
+ InstalledSkill *skillSupportItem `json:"skill,omitempty"`
+}
+
+type installedSkillOriginMeta struct {
+ Version int `json:"version"`
+ OriginKind string `json:"origin_kind,omitempty"`
+ Registry string `json:"registry,omitempty"`
+ Slug string `json:"slug,omitempty"`
+ RegistryURL string `json:"registry_url,omitempty"`
+ InstalledVersion string `json:"installed_version,omitempty"`
+ InstalledAt int64 `json:"installed_at"`
}
var (
skillNameSanitizer = regexp.MustCompile(`[^a-z0-9-]+`)
importedSkillFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
skillFrontmatterStripper = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
+ persistSkillOriginMeta = writeSkillOriginMeta
+ workspaceSkillWriteMu sync.Mutex
+ errImportedSkillExists = errors.New("skill already exists")
+)
+
+const (
+ maxImportedSkillSize = 1 << 20
+ maxRegistrySearchFanout = 1000
)
func (h *Handler) registerSkillRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/skills", h.handleListSkills)
mux.HandleFunc("GET /api/skills/{name}", h.handleGetSkill)
+ mux.HandleFunc("GET /api/skills/search", h.handleSearchSkills)
+ mux.HandleFunc("POST /api/skills/install", h.handleInstallSkill)
mux.HandleFunc("POST /api/skills/import", h.handleImportSkill)
mux.HandleFunc("DELETE /api/skills/{name}", h.handleDeleteSkill)
}
@@ -46,11 +122,15 @@ func (h *Handler) handleListSkills(w http.ResponseWriter, r *http.Request) {
return
}
- loader := newSkillsLoader(cfg.WorkspacePath())
+ items, err := buildSkillSupportItems(cfg)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to build skill list: %v", err), http.StatusInternalServerError)
+ return
+ }
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(skillSupportResponse{
- Skills: loader.ListSkills(),
+ Skills: items,
})
}
@@ -61,16 +141,18 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {
return
}
- loader := newSkillsLoader(cfg.WorkspacePath())
+ skillItems, err := buildSkillSupportItems(cfg)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to build skill list: %v", err), http.StatusInternalServerError)
+ return
+ }
name := r.PathValue("name")
- allSkills := loader.ListSkills()
-
- for _, skill := range allSkills {
- if skill.Name != name {
+ for _, skillItem := range skillItems {
+ if skillItem.Name != name {
continue
}
- content, err := loadSkillContent(skill.Path)
+ content, err := loadSkillContent(skillItem.Path)
if err != nil {
http.Error(w, "Skill content not found", http.StatusNotFound)
return
@@ -78,11 +160,8 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(skillDetailResponse{
- Name: skill.Name,
- Path: skill.Path,
- Source: skill.Source,
- Description: skill.Description,
- Content: content,
+ skillSupportItem: skillItem,
+ Content: content,
})
return
}
@@ -90,6 +169,276 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Skill not found", http.StatusNotFound)
}
+func (h *Handler) handleSearchSkills(w http.ResponseWriter, r *http.Request) {
+ cfg, loadErr := config.LoadConfig(h.configPath)
+ if loadErr != nil {
+ http.Error(w, fmt.Sprintf("Failed to load config: %v", loadErr), http.StatusInternalServerError)
+ return
+ }
+ if registryErr := ensureSkillRegistryToolEnabled(cfg, "find_skills"); registryErr != nil {
+ http.Error(w, registryErr.Error(), http.StatusBadRequest)
+ return
+ }
+
+ query := strings.TrimSpace(r.URL.Query().Get("q"))
+
+ limit := 20
+ if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" {
+ parsedLimit, parseErr := strconv.Atoi(rawLimit)
+ if parseErr != nil || parsedLimit < 1 || parsedLimit > 50 {
+ http.Error(w, "limit must be between 1 and 50", http.StatusBadRequest)
+ return
+ }
+ limit = parsedLimit
+ }
+ offset := 0
+ if rawOffset := strings.TrimSpace(r.URL.Query().Get("offset")); rawOffset != "" {
+ parsedOffset, parseErr := strconv.Atoi(rawOffset)
+ if parseErr != nil || parsedOffset < 0 {
+ http.Error(w, "offset must be 0 or greater", http.StatusBadRequest)
+ return
+ }
+ offset = parsedOffset
+ }
+
+ installedSkills, err := buildOccupiedWorkspaceSkillsByDirectory(cfg)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to inspect installed skills: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ if query == "" {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(skillSearchResponse{
+ Results: []skillSearchResultItem{},
+ Limit: limit,
+ Offset: offset,
+ HasMore: false,
+ })
+ return
+ }
+
+ registryMgr := newSkillsRegistryManager(cfg)
+ searchLimit := offset + limit + 1
+ if searchLimit > maxRegistrySearchFanout {
+ searchLimit = maxRegistrySearchFanout
+ }
+ results, err := registryMgr.SearchAll(r.Context(), query, searchLimit)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to search skills: %v", err), http.StatusBadGateway)
+ return
+ }
+
+ if offset > len(results) {
+ offset = len(results)
+ }
+
+ end := offset + limit
+ if end > len(results) {
+ end = len(results)
+ }
+
+ pageResults := results[offset:end]
+ response := make([]skillSearchResultItem, 0, len(pageResults))
+ for _, result := range pageResults {
+ installedSkill, installed := installedSkills[result.Slug]
+ if !installed {
+ registry := registryMgr.GetRegistry(result.RegistryName)
+ if registry != nil {
+ dirName, err := registry.ResolveInstallDirName(result.Slug)
+ if err == nil {
+ installedSkill, installed = installedSkills[dirName]
+ }
+ }
+ }
+ item := skillSearchResultItem{
+ Score: result.Score,
+ Slug: result.Slug,
+ DisplayName: result.DisplayName,
+ Summary: result.Summary,
+ Version: result.Version,
+ RegistryName: result.RegistryName,
+ URL: registrySkillURL(cfg, result.RegistryName, result.Slug, result.Version),
+ Installed: installed,
+ }
+ if installed {
+ item.InstalledName = installedSkill.Name
+ }
+ response = append(response, item)
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ nextOffset := 0
+ hasMore := len(results) > end
+ if hasMore {
+ nextOffset = end
+ }
+ json.NewEncoder(w).Encode(skillSearchResponse{
+ Results: response,
+ Limit: limit,
+ Offset: offset,
+ NextOffset: nextOffset,
+ HasMore: hasMore,
+ })
+}
+
+func (h *Handler) handleInstallSkill(w http.ResponseWriter, r *http.Request) {
+ cfg, loadErr := config.LoadConfig(h.configPath)
+ if loadErr != nil {
+ http.Error(w, fmt.Sprintf("Failed to load config: %v", loadErr), http.StatusInternalServerError)
+ return
+ }
+ if registryErr := ensureSkillRegistryToolEnabled(cfg, "install_skill"); registryErr != nil {
+ http.Error(w, registryErr.Error(), http.StatusBadRequest)
+ return
+ }
+
+ var req installSkillRequest
+ if decodeErr := json.NewDecoder(r.Body).Decode(&req); decodeErr != nil {
+ http.Error(w, fmt.Sprintf("Invalid JSON: %v", decodeErr), http.StatusBadRequest)
+ return
+ }
+
+ req.Slug = strings.TrimSpace(req.Slug)
+ req.Registry = strings.TrimSpace(req.Registry)
+ req.Version = strings.TrimSpace(req.Version)
+ if req.Registry == "" {
+ req.Registry = defaultInstallSkillRegistry
+ }
+
+ if validateErr := utils.ValidateSkillIdentifier(req.Registry); validateErr != nil {
+ http.Error(
+ w,
+ fmt.Sprintf("invalid registry %q: error: %s", req.Registry, validateErr.Error()),
+ http.StatusBadRequest,
+ )
+ return
+ }
+
+ registryMgr := newSkillsRegistryManager(cfg)
+ registry := registryMgr.GetRegistry(req.Registry)
+ if registry == nil {
+ http.Error(w, fmt.Sprintf("registry %q not found", req.Registry), http.StatusBadRequest)
+ return
+ }
+ dirName, err := registry.ResolveInstallDirName(req.Slug)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("invalid slug %q: error: %s", req.Slug, err.Error()), http.StatusBadRequest)
+ return
+ }
+
+ workspace := cfg.WorkspacePath()
+ skillsRoot := filepath.Join(workspace, "skills")
+ targetDir := filepath.Join(workspace, "skills", dirName)
+ workspaceSkillWriteMu.Lock()
+ defer workspaceSkillWriteMu.Unlock()
+
+ targetExists := false
+ if _, statErr := os.Stat(targetDir); statErr == nil {
+ targetExists = true
+ } else if !os.IsNotExist(statErr) {
+ http.Error(w, fmt.Sprintf("Failed to inspect install target: %v", statErr), http.StatusInternalServerError)
+ return
+ }
+
+ if !req.Force && targetExists {
+ http.Error(w, fmt.Sprintf("skill %q already installed at %s", dirName, targetDir), http.StatusConflict)
+ return
+ }
+ if mkdirErr := os.MkdirAll(skillsRoot, 0o755); mkdirErr != nil {
+ http.Error(w, fmt.Sprintf("Failed to create skills directory: %v", mkdirErr), http.StatusInternalServerError)
+ return
+ }
+
+ stagedWorkspaceRoot, stagedTargetDir, err := createStagedSkillInstall(skillsRoot, dirName)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to prepare staged install: %v", err), http.StatusInternalServerError)
+ return
+ }
+ defer os.RemoveAll(stagedWorkspaceRoot)
+
+ result, err := registry.DownloadAndInstall(r.Context(), req.Slug, req.Version, stagedTargetDir)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to install skill: %v", err), http.StatusBadGateway)
+ return
+ }
+ if result.IsMalwareBlocked {
+ http.Error(
+ w,
+ fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", req.Slug),
+ http.StatusForbidden,
+ )
+ return
+ }
+
+ if findWorkspaceSkillInfoByDirectory(stagedWorkspaceRoot, dirName) == nil {
+ http.Error(
+ w,
+ fmt.Sprintf("Failed to install skill: registry archive for %q is not a valid skill", req.Slug),
+ http.StatusBadGateway,
+ )
+ return
+ }
+
+ installedAt := time.Now().UnixMilli()
+ normalizedSlug, registryURL := skills.BuildInstallMetadataForRegistryInstance(registry, req.Slug, result.Version)
+ if err := persistSkillOriginMeta(stagedTargetDir, installedSkillOriginMeta{
+ Version: 1,
+ OriginKind: "third_party",
+ Registry: registry.Name(),
+ Slug: normalizedSlug,
+ RegistryURL: registryURL,
+ InstalledVersion: result.Version,
+ InstalledAt: installedAt,
+ }); err != nil {
+ http.Error(w, fmt.Sprintf("Failed to persist skill metadata: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ if err := commitStagedSkillInstall(
+ stagedWorkspaceRoot,
+ stagedTargetDir,
+ targetDir,
+ req.Force && targetExists,
+ ); err != nil {
+ http.Error(w, fmt.Sprintf("Failed to activate installed skill: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ validatedSkill := findWorkspaceSkillByDirectory(cfg, dirName)
+ if validatedSkill == nil {
+ http.Error(
+ w,
+ fmt.Sprintf("Failed to install skill: activated archive for %q is not a valid skill", req.Slug),
+ http.StatusBadGateway,
+ )
+ return
+ }
+
+ installedSkill := &skillSupportItem{
+ Name: validatedSkill.Name,
+ Path: validatedSkill.Path,
+ Source: validatedSkill.Source,
+ Description: validatedSkill.Description,
+ OriginKind: "third_party",
+ RegistryName: registry.Name(),
+ RegistryURL: registryURL,
+ InstalledVersion: result.Version,
+ InstalledAt: installedAt,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(installSkillResponse{
+ Status: "ok",
+ Slug: req.Slug,
+ Registry: registry.Name(),
+ Version: result.Version,
+ Summary: result.Summary,
+ IsSuspicious: result.IsSuspicious,
+ InstalledSkill: installedSkill,
+ })
+}
+
func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
@@ -110,54 +459,26 @@ func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) {
}
defer uploadedFile.Close()
- content, err := io.ReadAll(io.LimitReader(uploadedFile, (1<<20)+1))
+ content, err := io.ReadAll(io.LimitReader(uploadedFile, maxImportedSkillSize+1))
if err != nil {
http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusBadRequest)
return
}
- if len(content) > 1<<20 {
+ if len(content) > maxImportedSkillSize {
http.Error(w, "file exceeds 1MB limit", http.StatusBadRequest)
return
}
+ workspaceSkillWriteMu.Lock()
+ defer workspaceSkillWriteMu.Unlock()
- skillName, err := normalizeImportedSkillName(fileHeader.Filename, content)
+ importedSkill, statusCode, err := importUploadedSkill(cfg, fileHeader.Filename, content)
if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
+ http.Error(w, err.Error(), statusCode)
return
}
- content = normalizeImportedSkillContent(content, skillName)
-
- workspace := cfg.WorkspacePath()
- skillDir := filepath.Join(workspace, "skills", skillName)
- skillFile := filepath.Join(skillDir, "SKILL.md")
- if _, err := os.Stat(skillDir); err == nil {
- http.Error(w, "skill already exists", http.StatusConflict)
- return
- }
-
- if err := os.MkdirAll(skillDir, 0o755); err != nil {
- http.Error(w, fmt.Sprintf("Failed to create skill directory: %v", err), http.StatusInternalServerError)
- return
- }
- if err := os.WriteFile(skillFile, content, 0o644); err != nil {
- http.Error(w, fmt.Sprintf("Failed to save skill: %v", err), http.StatusInternalServerError)
- return
- }
-
- loader := newSkillsLoader(workspace)
- for _, skill := range loader.ListSkills() {
- if skill.Path == skillFile || (skill.Name == skillName && skill.Source == "workspace") {
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(skill)
- return
- }
- }
w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]string{
- "name": skillName,
- "path": skillFile,
- })
+ json.NewEncoder(w).Encode(importedSkill)
}
func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {
@@ -169,13 +490,17 @@ func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {
loader := newSkillsLoader(cfg.WorkspacePath())
name := r.PathValue("name")
+ workspaceSkillWriteMu.Lock()
+ defer workspaceSkillWriteMu.Unlock()
+
+ var matchedNonWorkspace bool
for _, skill := range loader.ListSkills() {
if skill.Name != name {
continue
}
if skill.Source != "workspace" {
- http.Error(w, "only workspace skills can be deleted", http.StatusBadRequest)
- return
+ matchedNonWorkspace = true
+ continue
}
if err := os.RemoveAll(filepath.Dir(skill.Path)); err != nil {
http.Error(w, fmt.Sprintf("Failed to delete skill: %v", err), http.StatusInternalServerError)
@@ -185,6 +510,10 @@ func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
return
}
+ if matchedNonWorkspace {
+ http.Error(w, "only workspace skills can be deleted", http.StatusBadRequest)
+ return
+ }
http.Error(w, "Skill not found", http.StatusNotFound)
}
@@ -197,12 +526,263 @@ func newSkillsLoader(workspace string) *skills.SkillsLoader {
)
}
+func newSkillsRegistryManager(cfg *config.Config) *skills.RegistryManager {
+ return skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills)
+}
+
+func ensureSkillRegistryToolEnabled(cfg *config.Config, toolName string) error {
+ if !cfg.Tools.IsToolEnabled("skills") {
+ return fmt.Errorf("tools.skills is disabled")
+ }
+ if !cfg.Tools.IsToolEnabled(toolName) {
+ return fmt.Errorf("%s is disabled", toolName)
+ }
+ return nil
+}
+
+func buildSkillSupportItems(cfg *config.Config) ([]skillSupportItem, error) {
+ rawSkills := newSkillsLoader(cfg.WorkspacePath()).ListSkills()
+ items := make([]skillSupportItem, 0, len(rawSkills))
+ for _, skill := range rawSkills {
+ item, err := enrichSkillInfo(cfg, skill)
+ if err != nil {
+ return nil, err
+ }
+ items = append(items, item)
+ }
+ return items, nil
+}
+
+func buildWorkspaceSkillItemsByDirectory(cfg *config.Config) (map[string]skillSupportItem, error) {
+ result := make(map[string]skillSupportItem)
+ items, err := buildSkillSupportItems(cfg)
+ if err != nil {
+ return nil, err
+ }
+ for _, skill := range items {
+ if skill.Source != "workspace" {
+ continue
+ }
+ dir := filepath.Base(filepath.Dir(skill.Path))
+ if dir == "" {
+ continue
+ }
+ result[dir] = skill
+ }
+ return result, nil
+}
+
+func buildOccupiedWorkspaceSkillsByDirectory(cfg *config.Config) (map[string]skillSupportItem, error) {
+ result := make(map[string]skillSupportItem)
+ items, err := buildSkillSupportItems(cfg)
+ if err != nil {
+ return nil, err
+ }
+ for _, skill := range items {
+ if skill.Source != "workspace" {
+ continue
+ }
+
+ dirName := filepath.Base(filepath.Dir(skill.Path))
+ if dirName != "" {
+ result[dirName] = skill
+ }
+ if meta, err := readInstalledSkillOriginMeta(skill.Path); err == nil && meta != nil && meta.Slug != "" {
+ key := skills.NormalizeInstallTargetForRegistry(cfg.Tools.Skills, meta.Registry, meta.Slug)
+ if key == "" {
+ key = meta.Slug
+ }
+ if key != "" {
+ result[key] = skill
+ }
+ }
+ }
+ return result, nil
+}
+
+func findWorkspaceSkillByDirectory(cfg *config.Config, directory string) *skillSupportItem {
+ items, err := buildWorkspaceSkillItemsByDirectory(cfg)
+ if err != nil {
+ return nil
+ }
+ skill, ok := items[directory]
+ if !ok {
+ return nil
+ }
+ return &skill
+}
+
+func findWorkspaceSkillInfoByDirectory(workspace, directory string) *skills.SkillInfo {
+ loader := skills.NewSkillsLoader(workspace, "", "")
+ for _, skill := range loader.ListSkills() {
+ if skill.Source != "workspace" {
+ continue
+ }
+ if filepath.Base(filepath.Dir(skill.Path)) != directory {
+ continue
+ }
+ skillCopy := skill
+ return &skillCopy
+ }
+ return nil
+}
+
+func createStagedSkillInstall(skillsRoot, slug string) (string, string, error) {
+ stagedWorkspaceRoot, err := os.MkdirTemp(skillsRoot, "."+slug+"-install-*")
+ if err != nil {
+ return "", "", err
+ }
+ stagedTargetDir := filepath.Join(stagedWorkspaceRoot, "skills", slug)
+ return stagedWorkspaceRoot, stagedTargetDir, nil
+}
+
+func commitStagedSkillInstall(stagedWorkspaceRoot, stagedTargetDir, targetDir string, replaceExisting bool) error {
+ if !replaceExisting {
+ return os.Rename(stagedTargetDir, targetDir)
+ }
+
+ backupDir, err := reserveTempDirPath(filepath.Dir(targetDir), "."+filepath.Base(targetDir)+"-backup-*")
+ if err != nil {
+ return err
+ }
+
+ if err := os.Rename(targetDir, backupDir); err != nil {
+ return fmt.Errorf("failed to move existing skill aside: %w", err)
+ }
+
+ if err := os.Rename(stagedTargetDir, targetDir); err != nil {
+ if rollbackErr := os.Rename(backupDir, targetDir); rollbackErr != nil {
+ return fmt.Errorf("failed to activate replacement: %w (rollback failed: %v)", err, rollbackErr)
+ }
+ return fmt.Errorf("failed to activate replacement: %w", err)
+ }
+
+ _ = os.RemoveAll(backupDir)
+ _ = os.RemoveAll(stagedWorkspaceRoot)
+ return nil
+}
+
+func reserveTempDirPath(parent, pattern string) (string, error) {
+ tempDir, err := os.MkdirTemp(parent, pattern)
+ if err != nil {
+ return "", err
+ }
+ if err := os.Remove(tempDir); err != nil {
+ return "", err
+ }
+ return tempDir, nil
+}
+
+func enrichSkillInfo(cfg *config.Config, skill skills.SkillInfo) (skillSupportItem, error) {
+ item := skillSupportItem{
+ Name: skill.Name,
+ Path: skill.Path,
+ Source: skill.Source,
+ Description: skill.Description,
+ OriginKind: "builtin",
+ }
+
+ switch skill.Source {
+ case "builtin":
+ item.OriginKind = "builtin"
+ case "global":
+ item.OriginKind = "builtin"
+ case "workspace":
+ meta, err := readInstalledSkillOriginMeta(skill.Path)
+ if err == nil && meta != nil {
+ switch meta.OriginKind {
+ case "manual":
+ item.OriginKind = "manual"
+ item.InstalledAt = meta.InstalledAt
+ case "third_party":
+ item.OriginKind = "third_party"
+ item.RegistryName = meta.Registry
+ item.RegistryURL = registrySkillURLFromMeta(cfg, meta)
+ item.InstalledVersion = meta.InstalledVersion
+ item.InstalledAt = meta.InstalledAt
+ default:
+ if meta.Registry != "" || meta.Slug != "" || meta.InstalledVersion != "" {
+ item.OriginKind = "third_party"
+ item.RegistryName = meta.Registry
+ item.RegistryURL = registrySkillURLFromMeta(cfg, meta)
+ item.InstalledVersion = meta.InstalledVersion
+ item.InstalledAt = meta.InstalledAt
+ } else {
+ item.OriginKind = "builtin"
+ item.InstalledAt = meta.InstalledAt
+ }
+ }
+ } else {
+ item.OriginKind = "builtin"
+ }
+ default:
+ item.OriginKind = "builtin"
+ }
+
+ return item, nil
+}
+
+func readInstalledSkillOriginMeta(skillPath string) (*installedSkillOriginMeta, error) {
+ metaPath := filepath.Join(filepath.Dir(skillPath), ".skill-origin.json")
+ data, err := os.ReadFile(metaPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ var meta installedSkillOriginMeta
+ if err := json.Unmarshal(data, &meta); err != nil {
+ return nil, err
+ }
+ return &meta, nil
+}
+
+func writeSkillOriginMeta(targetDir string, meta installedSkillOriginMeta) error {
+ data, err := json.MarshalIndent(meta, "", " ")
+ if err != nil {
+ return err
+ }
+ return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
+}
+
+func registrySkillURL(cfg *config.Config, registryName, slug, version string) string {
+ if cfg == nil || registryName == "" || slug == "" {
+ return ""
+ }
+ registry := skills.LookupRegistryFromToolsConfig(cfg.Tools.Skills, registryName)
+ if registry == nil {
+ return ""
+ }
+ return registry.SkillURL(slug, version)
+}
+
+func registrySkillURLFromMeta(cfg *config.Config, meta *installedSkillOriginMeta) string {
+ if meta == nil || meta.Slug == "" {
+ return ""
+ }
+ if meta.RegistryURL != "" {
+ return meta.RegistryURL
+ }
+ if cfg == nil || meta.Registry == "" {
+ return ""
+ }
+ return registrySkillURL(cfg, meta.Registry, meta.Slug, meta.InstalledVersion)
+}
+
func normalizeImportedSkillName(filename string, content []byte) (string, error) {
+ return normalizeImportedSkillNameWithHint(filename, "", content)
+}
+
+func normalizeImportedSkillNameWithHint(filename, directoryHint string, content []byte) (string, error) {
rawContent := strings.ReplaceAll(string(content), "\r\n", "\n")
rawContent = strings.ReplaceAll(rawContent, "\r", "\n")
metadata, _ := extractImportedSkillMetadata(rawContent)
raw := strings.TrimSpace(metadata["name"])
+ if raw == "" {
+ raw = strings.TrimSpace(directoryHint)
+ }
if raw == "" {
raw = strings.TrimSpace(strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)))
}
@@ -259,6 +839,210 @@ func normalizeImportedSkillContent(content []byte, skillName string) []byte {
return []byte(builder.String())
}
+func importUploadedSkill(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) {
+ if isImportedSkillArchive(filename, content) {
+ return importUploadedSkillArchive(cfg, filename, content)
+ }
+ return importUploadedMarkdownSkill(cfg, filename, content)
+}
+
+func importUploadedMarkdownSkill(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) {
+ skillName, err := normalizeImportedSkillName(filename, content)
+ if err != nil {
+ return nil, http.StatusBadRequest, err
+ }
+
+ normalizedContent := normalizeImportedSkillContent(content, skillName)
+ workspace := cfg.WorkspacePath()
+ skillDir := filepath.Join(workspace, "skills", skillName)
+ skillFile := filepath.Join(skillDir, "SKILL.md")
+
+ if err := ensureWorkspaceSkillDoesNotExist(skillDir); err != nil {
+ return nil, statusCodeForImportedSkillWriteError(err), err
+ }
+ if err := os.MkdirAll(skillDir, 0o755); err != nil {
+ return nil, http.StatusInternalServerError, fmt.Errorf("Failed to create skill directory: %v", err)
+ }
+ if err := fileutil.WriteFileAtomic(skillFile, normalizedContent, 0o644); err != nil {
+ _ = os.RemoveAll(skillDir)
+ return nil, http.StatusInternalServerError, fmt.Errorf("Failed to save skill: %v", err)
+ }
+
+ return finalizeImportedSkill(cfg, skillDir, skillName, false)
+}
+
+func importUploadedSkillArchive(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) {
+ tmpDir, tempDirErr := os.MkdirTemp("", "picoclaw-skill-import-*")
+ if tempDirErr != nil {
+ return nil, http.StatusInternalServerError, fmt.Errorf("Failed to create temp directory: %v", tempDirErr)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ archivePath := filepath.Join(tmpDir, "import.zip")
+ if writeErr := fileutil.WriteFileAtomic(archivePath, content, 0o600); writeErr != nil {
+ return nil, http.StatusInternalServerError, fmt.Errorf("Failed to stage uploaded archive: %v", writeErr)
+ }
+
+ extractDir := filepath.Join(tmpDir, "extract")
+ if extractErr := utils.ExtractZipFile(archivePath, extractDir); extractErr != nil {
+ return nil, http.StatusBadRequest, fmt.Errorf("invalid ZIP archive: %w", extractErr)
+ }
+
+ skillRoot, err := findImportedSkillRoot(extractDir)
+ if err != nil {
+ return nil, http.StatusBadRequest, err
+ }
+
+ skillFile := filepath.Join(skillRoot, "SKILL.md")
+ skillContent, err := os.ReadFile(skillFile)
+ if err != nil {
+ return nil, http.StatusBadRequest, fmt.Errorf("failed to read SKILL.md from archive: %w", err)
+ }
+
+ directoryHint := ""
+ if filepath.Clean(skillRoot) != filepath.Clean(extractDir) {
+ directoryHint = filepath.Base(skillRoot)
+ }
+ skillName, err := normalizeImportedSkillNameWithHint(filename, directoryHint, skillContent)
+ if err != nil {
+ return nil, http.StatusBadRequest, err
+ }
+
+ workspace := cfg.WorkspacePath()
+ skillDir := filepath.Join(workspace, "skills", skillName)
+ if err := ensureWorkspaceSkillDoesNotExist(skillDir); err != nil {
+ return nil, statusCodeForImportedSkillWriteError(err), err
+ }
+ if err := copyImportedSkillTree(skillRoot, skillDir); err != nil {
+ _ = os.RemoveAll(skillDir)
+ return nil, http.StatusInternalServerError, fmt.Errorf("Failed to save skill: %v", err)
+ }
+
+ normalizedContent := normalizeImportedSkillContent(skillContent, skillName)
+ if err := fileutil.WriteFileAtomic(filepath.Join(skillDir, "SKILL.md"), normalizedContent, 0o644); err != nil {
+ _ = os.RemoveAll(skillDir)
+ return nil, http.StatusInternalServerError, fmt.Errorf("Failed to normalize skill: %v", err)
+ }
+
+ return finalizeImportedSkill(cfg, skillDir, skillName, true)
+}
+
+func isImportedSkillArchive(filename string, content []byte) bool {
+ if strings.EqualFold(filepath.Ext(filename), ".zip") {
+ return true
+ }
+ return len(content) >= 4 && bytes.HasPrefix(content, []byte("PK\x03\x04"))
+}
+
+func ensureWorkspaceSkillDoesNotExist(skillDir string) error {
+ if _, err := os.Stat(skillDir); err == nil {
+ return errImportedSkillExists
+ } else if !os.IsNotExist(err) {
+ return fmt.Errorf("failed to inspect skill directory: %w", err)
+ }
+ return nil
+}
+
+func statusCodeForImportedSkillWriteError(err error) int {
+ if err == nil {
+ return http.StatusOK
+ }
+ if errors.Is(err, errImportedSkillExists) {
+ return http.StatusConflict
+ }
+ return http.StatusInternalServerError
+}
+
+func finalizeImportedSkill(
+ cfg *config.Config,
+ skillDir string,
+ skillName string,
+ requireValidatedSkill bool,
+) (*skillSupportItem, int, error) {
+ if err := persistSkillOriginMeta(skillDir, installedSkillOriginMeta{
+ Version: 1,
+ OriginKind: "manual",
+ InstalledAt: time.Now().UnixMilli(),
+ }); err != nil {
+ _ = os.RemoveAll(skillDir)
+ return nil, http.StatusInternalServerError, fmt.Errorf("Failed to persist skill metadata: %v", err)
+ }
+
+ if importedSkill := findWorkspaceSkillByDirectory(cfg, skillName); importedSkill != nil {
+ return importedSkill, http.StatusOK, nil
+ }
+
+ if requireValidatedSkill {
+ _ = os.RemoveAll(skillDir)
+ return nil, http.StatusBadRequest, fmt.Errorf("imported archive is not a valid skill")
+ }
+
+ return &skillSupportItem{
+ Name: skillName,
+ Path: filepath.Join(skillDir, "SKILL.md"),
+ Source: "workspace",
+ Description: "Imported skill",
+ OriginKind: "manual",
+ }, http.StatusOK, nil
+}
+
+func findImportedSkillRoot(extractDir string) (string, error) {
+ skillFiles := make([]string, 0, 1)
+ err := filepath.WalkDir(extractDir, func(path string, d fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if d.IsDir() {
+ return nil
+ }
+ if d.Name() == "SKILL.md" {
+ skillFiles = append(skillFiles, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return "", fmt.Errorf("failed to inspect ZIP archive: %w", err)
+ }
+
+ switch len(skillFiles) {
+ case 0:
+ return "", fmt.Errorf("ZIP archive must contain a SKILL.md file")
+ case 1:
+ return filepath.Dir(skillFiles[0]), nil
+ default:
+ return "", fmt.Errorf("ZIP archive must contain exactly one SKILL.md file")
+ }
+}
+
+func copyImportedSkillTree(srcDir, destDir string) error {
+ return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+
+ relPath, err := filepath.Rel(srcDir, path)
+ if err != nil {
+ return err
+ }
+ if relPath == "." {
+ return os.MkdirAll(destDir, 0o755)
+ }
+
+ destPath := filepath.Join(destDir, relPath)
+ info, err := d.Info()
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return os.MkdirAll(destPath, 0o755)
+ }
+ if !info.Mode().IsRegular() {
+ return fmt.Errorf("archive contains unsupported file %q", relPath)
+ }
+ return fileutil.CopyFile(path, destPath, info.Mode().Perm())
+ })
+}
+
func extractImportedSkillMetadata(raw string) (map[string]string, string) {
matches := importedSkillFrontmatter.FindStringSubmatch(raw)
if len(matches) != 2 {
@@ -309,14 +1093,7 @@ func loadSkillContent(path string) (string, error) {
}
func globalConfigDir() string {
- if home := os.Getenv(config.EnvHome); home != "" {
- return home
- }
- home, err := os.UserHomeDir()
- if err != nil {
- return ""
- }
- return filepath.Join(home, ".picoclaw")
+ return config.GetHome()
}
func builtinSkillsDir() string {
diff --git a/web/backend/api/skills_test.go b/web/backend/api/skills_test.go
index 3289d5b33..977ec693f 100644
--- a/web/backend/api/skills_test.go
+++ b/web/backend/api/skills_test.go
@@ -1,19 +1,40 @@
package api
import (
+ "archive/zip"
"bytes"
"encoding/json"
+ "errors"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
+ "strconv"
"testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
"github.com/sipeed/picoclaw/pkg/config"
)
+func setClawHubBaseURL(cfg *config.Config, baseURL string) {
+ registryCfg, _ := cfg.Tools.Skills.Registries.Get("clawhub")
+ registryCfg.BaseURL = baseURL
+ cfg.Tools.Skills.Registries.Set("clawhub", registryCfg)
+}
+
+func setGithubBaseURL(cfg *config.Config, baseURL string) {
+ registryCfg, ok := cfg.Tools.Skills.Registries.Get("github")
+ if !ok {
+ return
+ }
+ registryCfg.BaseURL = baseURL
+ cfg.Tools.Skills.Registries.Set("github", registryCfg)
+}
+
func TestHandleListSkills(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -99,8 +120,10 @@ func TestHandleListSkills(t *testing.T) {
}
gotSkills := make(map[string]string, len(resp.Skills))
+ gotOriginKinds := make(map[string]string, len(resp.Skills))
for _, skill := range resp.Skills {
gotSkills[skill.Name] = skill.Source
+ gotOriginKinds[skill.Name] = skill.OriginKind
}
if gotSkills["workspace-skill"] != "workspace" {
t.Fatalf("workspace-skill source = %q, want workspace", gotSkills["workspace-skill"])
@@ -111,6 +134,15 @@ func TestHandleListSkills(t *testing.T) {
if gotSkills["builtin-skill"] != "builtin" {
t.Fatalf("builtin-skill source = %q, want builtin", gotSkills["builtin-skill"])
}
+ if gotOriginKinds["workspace-skill"] != "builtin" {
+ t.Fatalf("workspace-skill origin_kind = %q, want builtin", gotOriginKinds["workspace-skill"])
+ }
+ if gotOriginKinds["global-skill"] != "builtin" {
+ t.Fatalf("global-skill origin_kind = %q, want builtin", gotOriginKinds["global-skill"])
+ }
+ if gotOriginKinds["builtin-skill"] != "builtin" {
+ t.Fatalf("builtin-skill origin_kind = %q, want builtin", gotOriginKinds["builtin-skill"])
+ }
}
func TestHandleGetSkill(t *testing.T) {
@@ -162,6 +194,9 @@ func TestHandleGetSkill(t *testing.T) {
if resp.Name != "viewer-skill" || resp.Source != "workspace" || resp.Description != "Viewable skill" {
t.Fatalf("unexpected response: %#v", resp)
}
+ if resp.OriginKind != "builtin" {
+ t.Fatalf("resp.OriginKind = %q, want builtin", resp.OriginKind)
+ }
if resp.Content != "# Viewer Skill\n\nThis is visible content.\n" {
t.Fatalf("content = %q", resp.Content)
}
@@ -271,6 +306,17 @@ func TestHandleImportSkill(t *testing.T) {
if string(content) != expected {
t.Fatalf("saved skill content mismatch:\n%s", string(content))
}
+ metaContent, err := os.ReadFile(filepath.Join(workspace, "skills", "plain-skill", ".skill-origin.json"))
+ if err != nil {
+ t.Fatalf("ReadFile(origin metadata) error = %v", err)
+ }
+ var originMeta installedSkillOriginMeta
+ if err := json.Unmarshal(metaContent, &originMeta); err != nil {
+ t.Fatalf("Unmarshal(origin metadata) error = %v", err)
+ }
+ if originMeta.OriginKind != "manual" {
+ t.Fatalf("originMeta.OriginKind = %q, want manual", originMeta.OriginKind)
+ }
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/api/skills", nil)
@@ -293,6 +339,174 @@ func TestHandleImportSkill(t *testing.T) {
}
}
+func TestHandleImportSkillZip(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ zipContent := buildSkillZip(t, map[string]string{
+ "Wrapped Skill/SKILL.md": "---\nname: wrapped-skill\ndescription: Wrapped skill\n---\n# Wrapped Skill\n\nUse this skill from zip.\n",
+ "Wrapped Skill/docs/README.md": "# Extra file\n",
+ })
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ part, createErr := writer.CreateFormFile("file", "Wrapped Skill.zip")
+ if createErr != nil {
+ t.Fatalf("CreateFormFile() error = %v", createErr)
+ }
+ if _, writeErr := part.Write(zipContent); writeErr != nil {
+ t.Fatalf("Write(zipContent) error = %v", writeErr)
+ }
+ if closeErr := writer.Close(); closeErr != nil {
+ t.Fatalf("Close() error = %v", closeErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ skillDir := filepath.Join(workspace, "skills", "wrapped-skill")
+ skillFile := filepath.Join(skillDir, "SKILL.md")
+ content, err := os.ReadFile(skillFile)
+ if err != nil {
+ t.Fatalf("ReadFile() error = %v", err)
+ }
+ expected := "---\nname: wrapped-skill\ndescription: Wrapped skill\n---\n\n# Wrapped Skill\n\nUse this skill from zip.\n"
+ if string(content) != expected {
+ t.Fatalf("saved skill content mismatch:\n%s", string(content))
+ }
+
+ extraFile := filepath.Join(skillDir, "docs", "README.md")
+ extraContent, err := os.ReadFile(extraFile)
+ if err != nil {
+ t.Fatalf("ReadFile(extra file) error = %v", err)
+ }
+ if string(extraContent) != "# Extra file\n" {
+ t.Fatalf("extra file content = %q", string(extraContent))
+ }
+}
+
+func TestHandleImportSkillZipRejectsArchiveWithoutSkill(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ zipContent := buildSkillZip(t, map[string]string{
+ "README.md": "# Not a skill\n",
+ })
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ part, err := writer.CreateFormFile("file", "invalid.zip")
+ if err != nil {
+ t.Fatalf("CreateFormFile() error = %v", err)
+ }
+ if _, err := part.Write(zipContent); err != nil {
+ t.Fatalf("Write(zipContent) error = %v", err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatalf("Close() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
+ }
+ if _, err := os.Stat(filepath.Join(workspace, "skills", "invalid")); !os.IsNotExist(err) {
+ t.Fatalf("invalid archive should not leave behind a skill dir, stat err=%v", err)
+ }
+}
+
+func TestHandleImportSkillRollsBackOnOriginMetadataWriteFailure(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ previousPersist := persistSkillOriginMeta
+ persistSkillOriginMeta = func(targetDir string, meta installedSkillOriginMeta) error {
+ return errors.New("forced metadata failure")
+ }
+ defer func() {
+ persistSkillOriginMeta = previousPersist
+ }()
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ part, err := writer.CreateFormFile("file", "Rollback Skill.md")
+ if err != nil {
+ t.Fatalf("CreateFormFile() error = %v", err)
+ }
+ if _, err := io.WriteString(part, "# Rollback Skill\n"); err != nil {
+ t.Fatalf("WriteString() error = %v", err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatalf("Close() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusInternalServerError, rec.Body.String())
+ }
+
+ skillDir := filepath.Join(workspace, "skills", "rollback-skill")
+ if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
+ t.Fatalf("skill directory should be removed after metadata write failure, stat err=%v", err)
+ }
+}
+
func TestHandleDeleteSkill(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -334,3 +548,1316 @@ func TestHandleDeleteSkill(t *testing.T) {
t.Fatalf("skill directory should be removed, stat err=%v", err)
}
}
+
+func TestHandleDeleteSkillPrefersWorkspaceMatch(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ homeDir := t.TempDir()
+ t.Setenv(config.EnvHome, homeDir)
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ workspaceSkillDir := filepath.Join(workspace, "skills", "delete-me-workspace")
+ if err := os.MkdirAll(workspaceSkillDir, 0o755); err != nil {
+ t.Fatalf("MkdirAll(workspace) error = %v", err)
+ }
+ if err := os.WriteFile(
+ filepath.Join(workspaceSkillDir, "SKILL.md"),
+ []byte("---\nname: delete-me\ndescription: workspace delete me\n---\n"),
+ 0o644,
+ ); err != nil {
+ t.Fatalf("WriteFile(workspace) error = %v", err)
+ }
+
+ globalSkillDir := filepath.Join(homeDir, "skills", "delete-me-global")
+ if err := os.MkdirAll(globalSkillDir, 0o755); err != nil {
+ t.Fatalf("MkdirAll(global) error = %v", err)
+ }
+ if err := os.WriteFile(
+ filepath.Join(globalSkillDir, "SKILL.md"),
+ []byte("---\nname: delete-me\ndescription: global delete me\n---\n"),
+ 0o644,
+ ); err != nil {
+ t.Fatalf("WriteFile(global) error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodDelete, "/api/skills/delete-me", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if _, err := os.Stat(workspaceSkillDir); !os.IsNotExist(err) {
+ t.Fatalf("workspace skill directory should be removed, stat err=%v", err)
+ }
+ if _, err := os.Stat(globalSkillDir); err != nil {
+ t.Fatalf("global skill directory should remain, stat err=%v", err)
+ }
+}
+
+func TestHandleSearchSkills(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ if err := os.MkdirAll(filepath.Join(workspace, "skills", "github"), 0o755); err != nil {
+ t.Fatalf("MkdirAll() error = %v", err)
+ }
+ if err := os.WriteFile(
+ filepath.Join(workspace, "skills", "github", "SKILL.md"),
+ []byte("---\nname: github\ndescription: Installed GitHub skill\n---\n# GitHub\n"),
+ 0o644,
+ ); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/search" {
+ http.NotFound(w, r)
+ return
+ }
+ if got := r.URL.Query().Get("q"); got != "github" {
+ t.Fatalf("query = %q, want github", got)
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "results": []map[string]any{
+ {
+ "score": 0.95,
+ "slug": "github",
+ "displayName": "GitHub",
+ "summary": "GitHub integration skill",
+ "version": "1.2.3",
+ },
+ {
+ "score": 0.87,
+ "slug": "jira",
+ "displayName": "Jira",
+ "summary": "Issue tracker skill",
+ "version": "0.9.0",
+ },
+ },
+ })
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=5", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp skillSearchResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if resp.Limit != 5 {
+ t.Fatalf("limit = %d, want 5", resp.Limit)
+ }
+ if resp.Offset != 0 {
+ t.Fatalf("offset = %d, want 0", resp.Offset)
+ }
+ if resp.HasMore {
+ t.Fatalf("has_more = true, want false")
+ }
+ if len(resp.Results) != 2 {
+ t.Fatalf("results count = %d, want 2", len(resp.Results))
+ }
+ if resp.Results[0].URL != server.URL+"/skills/github" {
+ t.Fatalf("first result URL = %q, want %q", resp.Results[0].URL, server.URL+"/skills/github")
+ }
+ if !resp.Results[0].Installed || resp.Results[0].InstalledName != "github" {
+ t.Fatalf("first result should be treated as occupying the workspace slug, got %#v", resp.Results[0])
+ }
+ if resp.Results[1].Installed {
+ t.Fatalf("second result should not be installed, got %#v", resp.Results[1])
+ }
+}
+
+func TestHandleSearchSkillsUsesGitHubResultVersionInURL(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v3/search/code" {
+ http.NotFound(w, r)
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "path": "skills/pr-review/SKILL.md",
+ "score": 10,
+ "repository": map[string]any{
+ "full_name": "foo/bar",
+ "name": "bar",
+ "description": "Review pull requests",
+ "default_branch": "master",
+ },
+ },
+ },
+ })
+ }))
+ defer server.Close()
+
+ setGithubBaseURL(cfg, server.URL)
+ clawHubRegistry, _ := cfg.Tools.Skills.Registries.Get("clawhub")
+ clawHubRegistry.Enabled = false
+ cfg.Tools.Skills.Registries.Set("clawhub", clawHubRegistry)
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp skillSearchResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Results) != 1 {
+ t.Fatalf("results count = %d, want 1", len(resp.Results))
+ }
+ if resp.Results[0].URL != server.URL+"/foo/bar/tree/master/skills/pr-review" {
+ t.Fatalf("result URL = %q", resp.Results[0].URL)
+ }
+}
+
+func TestHandleSearchSkillsGitHubRateLimitDegradesGracefully(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v3/search/code" {
+ http.NotFound(w, r)
+ return
+ }
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte(`{"message":"API rate limit exceeded for 1.2.3.4"}`))
+ }))
+ defer server.Close()
+
+ setGithubBaseURL(cfg, server.URL)
+ clawHubRegistry, _ := cfg.Tools.Skills.Registries.Get("clawhub")
+ clawHubRegistry.Enabled = false
+ cfg.Tools.Skills.Registries.Set("clawhub", clawHubRegistry)
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp skillSearchResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Results) != 0 {
+ t.Fatalf("results count = %d, want 0", len(resp.Results))
+ }
+}
+
+func TestHandleSearchSkillsPagination(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/search" {
+ http.NotFound(w, r)
+ return
+ }
+ if got := r.URL.Query().Get("limit"); got != "5" {
+ t.Fatalf("limit = %q, want 5", got)
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "results": []map[string]any{
+ {
+ "score": 0.99,
+ "slug": "skill-1",
+ "displayName": "Skill 1",
+ "summary": "Summary 1",
+ "version": "1.0.0",
+ },
+ {
+ "score": 0.98,
+ "slug": "skill-2",
+ "displayName": "Skill 2",
+ "summary": "Summary 2",
+ "version": "1.0.0",
+ },
+ {
+ "score": 0.97,
+ "slug": "skill-3",
+ "displayName": "Skill 3",
+ "summary": "Summary 3",
+ "version": "1.0.0",
+ },
+ {
+ "score": 0.96,
+ "slug": "skill-4",
+ "displayName": "Skill 4",
+ "summary": "Summary 4",
+ "version": "1.0.0",
+ },
+ },
+ })
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=2&offset=2", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp skillSearchResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if resp.Limit != 2 {
+ t.Fatalf("limit = %d, want 2", resp.Limit)
+ }
+ if resp.Offset != 2 {
+ t.Fatalf("offset = %d, want 2", resp.Offset)
+ }
+ if resp.HasMore {
+ t.Fatalf("has_more = true, want false")
+ }
+ if len(resp.Results) != 2 {
+ t.Fatalf("results count = %d, want 2", len(resp.Results))
+ }
+ if resp.Results[0].Slug != "skill-3" || resp.Results[1].Slug != "skill-4" {
+ t.Fatalf("unexpected paged results: %#v", resp.Results)
+ }
+ if resp.NextOffset != 0 {
+ t.Fatalf("next_offset = %d, want 0", resp.NextOffset)
+ }
+}
+
+func TestHandleSearchSkillsClampsRegistryFanout(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/search" {
+ http.NotFound(w, r)
+ return
+ }
+ if got := r.URL.Query().Get("limit"); got != strconv.Itoa(maxRegistrySearchFanout) {
+ t.Fatalf("limit = %q, want %d", got, maxRegistrySearchFanout)
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "results": []map[string]any{
+ {
+ "score": 0.99,
+ "slug": "skill-1",
+ "displayName": "Skill 1",
+ "summary": "Summary 1",
+ "version": "1.0.0",
+ },
+ },
+ })
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=20&offset=100000", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp skillSearchResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Results) != 0 {
+ t.Fatalf("results count = %d, want 0", len(resp.Results))
+ }
+}
+
+func TestHandleInstallSkill(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ zipContent := buildSkillZip(t, map[string]string{
+ "SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n\nUse this skill.\n",
+ })
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/search":
+ json.NewEncoder(w).Encode(map[string]any{
+ "results": []map[string]any{
+ {
+ "score": 0.95,
+ "slug": "github",
+ "displayName": "GitHub",
+ "summary": "GitHub registry skill",
+ "version": "1.2.3",
+ },
+ },
+ })
+ case "/api/v1/skills/github":
+ json.NewEncoder(w).Encode(map[string]any{
+ "slug": "github",
+ "displayName": "GitHub",
+ "summary": "GitHub registry skill",
+ "latestVersion": map[string]any{
+ "version": "1.2.3",
+ },
+ "moderation": map[string]any{
+ "isMalwareBlocked": false,
+ "isSuspicious": false,
+ },
+ })
+ case "/api/v1/download":
+ if got := r.URL.Query().Get("slug"); got != "github" {
+ t.Fatalf("slug = %q, want github", got)
+ }
+ if got := r.URL.Query().Get("version"); got != "1.2.3" {
+ t.Fatalf("version = %q, want 1.2.3", got)
+ }
+ w.Header().Set("Content-Type", "application/zip")
+ _, _ = w.Write(zipContent)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ body, err := json.Marshal(installSkillRequest{
+ Slug: "github",
+ Registry: "clawhub",
+ })
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp installSkillResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if resp.Status != "ok" || resp.Version != "1.2.3" || resp.InstalledSkill == nil {
+ t.Fatalf("unexpected response: %#v", resp)
+ }
+ if resp.InstalledSkill.OriginKind != "third_party" {
+ t.Fatalf("resp.InstalledSkill.OriginKind = %q, want third_party", resp.InstalledSkill.OriginKind)
+ }
+ if resp.InstalledSkill.RegistryURL != server.URL+"/skills/github" {
+ t.Fatalf(
+ "resp.InstalledSkill.RegistryURL = %q, want %q",
+ resp.InstalledSkill.RegistryURL,
+ server.URL+"/skills/github",
+ )
+ }
+
+ skillFile := filepath.Join(workspace, "skills", "github", "SKILL.md")
+ if _, err := os.Stat(skillFile); err != nil {
+ t.Fatalf("installed skill file missing: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(workspace, "skills", "github", ".skill-origin.json")); err != nil {
+ t.Fatalf("origin metadata missing: %v", err)
+ }
+
+ detailRec := httptest.NewRecorder()
+ detailReq := httptest.NewRequest(http.MethodGet, "/api/skills/github", nil)
+ mux.ServeHTTP(detailRec, detailReq)
+
+ if detailRec.Code != http.StatusOK {
+ t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusOK, detailRec.Body.String())
+ }
+
+ var detailResp skillDetailResponse
+ if err := json.Unmarshal(detailRec.Body.Bytes(), &detailResp); err != nil {
+ t.Fatalf("Unmarshal(detail response) error = %v", err)
+ }
+ if detailResp.RegistryURL != server.URL+"/skills/github" {
+ t.Fatalf("detailResp.RegistryURL = %q, want %q", detailResp.RegistryURL, server.URL+"/skills/github")
+ }
+
+ searchRec := httptest.NewRecorder()
+ searchReq := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=5", nil)
+ mux.ServeHTTP(searchRec, searchReq)
+
+ if searchRec.Code != http.StatusOK {
+ t.Fatalf("search status = %d, want %d, body=%s", searchRec.Code, http.StatusOK, searchRec.Body.String())
+ }
+
+ var searchResp skillSearchResponse
+ if err := json.Unmarshal(searchRec.Body.Bytes(), &searchResp); err != nil {
+ t.Fatalf("Unmarshal(search response) error = %v", err)
+ }
+ if len(searchResp.Results) != 1 {
+ t.Fatalf("search results count = %d, want 1", len(searchResp.Results))
+ }
+ if !searchResp.Results[0].Installed || searchResp.Results[0].InstalledName != "github" {
+ t.Fatalf("search result should be treated as installed after registry install, got %#v", searchResp.Results[0])
+ }
+}
+
+func TestHandleInstallSkillForcePreservesExistingSkillOnFailure(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ skillDir := filepath.Join(workspace, "skills", "github")
+ if err := os.MkdirAll(skillDir, 0o755); err != nil {
+ t.Fatalf("MkdirAll() error = %v", err)
+ }
+ oldContent := []byte("---\nname: github\ndescription: Existing skill\n---\n# Existing\n")
+ if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), oldContent, 0o644); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/skills/github":
+ json.NewEncoder(w).Encode(map[string]any{
+ "slug": "github",
+ "displayName": "GitHub",
+ "summary": "GitHub registry skill",
+ "latestVersion": map[string]any{
+ "version": "1.2.3",
+ },
+ "moderation": map[string]any{
+ "isMalwareBlocked": false,
+ "isSuspicious": false,
+ },
+ })
+ case "/api/v1/download":
+ http.Error(w, "upstream download failed", http.StatusBadGateway)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ body, err := json.Marshal(installSkillRequest{
+ Slug: "github",
+ Registry: "clawhub",
+ Force: true,
+ })
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadGateway {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadGateway, rec.Body.String())
+ }
+
+ gotContent, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md"))
+ if err != nil {
+ t.Fatalf("ReadFile() error = %v", err)
+ }
+ if !bytes.Equal(gotContent, oldContent) {
+ t.Fatalf("existing skill should remain unchanged, got:\n%s", string(gotContent))
+ }
+}
+
+func TestHandleInstallSkillDefaultsRegistryToGitHub(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v3/repos/foo/bar":
+ json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"})
+ case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
+ assert.Equal(t, "ref=master", r.URL.RawQuery)
+ json.NewEncoder(w).Encode([]map[string]any{
+ {
+ "type": "file",
+ "name": "SKILL.md",
+ "download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
+ },
+ })
+ case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
+ _, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n"))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
+ if !ok {
+ t.Fatalf("github registry missing from default config")
+ }
+ githubRegistry.BaseURL = server.URL
+ cfg.Tools.Skills.Registries.Set("github", githubRegistry)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ body, err := json.Marshal(installSkillRequest{
+ Slug: "foo/bar/.agents/skills/pr-review",
+ })
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp installSkillResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if resp.Registry != "github" {
+ t.Fatalf("resp.Registry = %q, want github", resp.Registry)
+ }
+}
+
+func TestHandleInstallSkillTracksGitHubURLInstallsAsInstalled(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v3/repos/foo/bar":
+ json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"})
+ case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
+ assert.Equal(t, "ref=master", r.URL.RawQuery)
+ json.NewEncoder(w).Encode([]map[string]any{{
+ "type": "file",
+ "name": "SKILL.md",
+ "download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
+ }})
+ case "/api/v3/search/code":
+ json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{{
+ "path": ".agents/skills/pr-review/SKILL.md",
+ "score": 10,
+ "repository": map[string]any{
+ "full_name": "foo/bar",
+ "name": "bar",
+ "description": "PR review skill",
+ "default_branch": "master",
+ },
+ }},
+ })
+ case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
+ _, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n"))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ setGithubBaseURL(cfg, server.URL)
+ clawHubRegistry, _ := cfg.Tools.Skills.Registries.Get("clawhub")
+ clawHubRegistry.Enabled = false
+ cfg.Tools.Skills.Registries.Set("clawhub", clawHubRegistry)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ installBody, err := json.Marshal(installSkillRequest{
+ Slug: server.URL + "/foo/bar/tree/master/.agents/skills/pr-review",
+ })
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+
+ installRec := httptest.NewRecorder()
+ installReq := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(installBody))
+ installReq.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(installRec, installReq)
+
+ if installRec.Code != http.StatusOK {
+ t.Fatalf("install status = %d, want %d, body=%s", installRec.Code, http.StatusOK, installRec.Body.String())
+ }
+
+ searchRec := httptest.NewRecorder()
+ searchReq := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil)
+ mux.ServeHTTP(searchRec, searchReq)
+
+ if searchRec.Code != http.StatusOK {
+ t.Fatalf("search status = %d, want %d, body=%s", searchRec.Code, http.StatusOK, searchRec.Body.String())
+ }
+
+ var searchResp skillSearchResponse
+ if err := json.Unmarshal(searchRec.Body.Bytes(), &searchResp); err != nil {
+ t.Fatalf("Unmarshal(search response) error = %v", err)
+ }
+ if len(searchResp.Results) != 1 {
+ t.Fatalf("search results count = %d, want 1", len(searchResp.Results))
+ }
+ if !searchResp.Results[0].Installed || searchResp.Results[0].InstalledName != "pr-review" {
+ t.Fatalf("search result should be treated as installed after URL install, got %#v", searchResp.Results[0])
+ }
+}
+
+func TestHandleSearchSkillsMarksDirectoryCollisionAsInstalled(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ skillDir := filepath.Join(workspace, "skills", "pr-review")
+ if err := os.MkdirAll(skillDir, 0o755); err != nil {
+ t.Fatalf("MkdirAll() error = %v", err)
+ }
+ if err := os.WriteFile(
+ filepath.Join(skillDir, "SKILL.md"),
+ []byte("---\nname: pr-review\ndescription: Workspace PR review skill\n---\n# PR Review\n"),
+ 0o644,
+ ); err != nil {
+ t.Fatalf("WriteFile(SKILL.md) error = %v", err)
+ }
+ if err := writeSkillOriginMeta(skillDir, installedSkillOriginMeta{
+ Version: 1,
+ OriginKind: "third_party",
+ Registry: "github",
+ Slug: "foo/bar/.agents/skills/pr-review",
+ RegistryURL: "https://github.com/foo/bar/tree/master/.agents/skills/pr-review",
+ InstalledVersion: "master",
+ InstalledAt: time.Now().UnixMilli(),
+ }); err != nil {
+ t.Fatalf("writeSkillOriginMeta() error = %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/search":
+ json.NewEncoder(w).Encode(map[string]any{
+ "results": []map[string]any{{
+ "slug": "pr-review",
+ "displayName": "PR Review",
+ "summary": "ClawHub PR review skill",
+ "version": "1.2.3",
+ }},
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ githubRegistry, _ := cfg.Tools.Skills.Registries.Get("github")
+ githubRegistry.Enabled = false
+ cfg.Tools.Skills.Registries.Set("github", githubRegistry)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp skillSearchResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Results) != 1 {
+ t.Fatalf("results count = %d, want 1", len(resp.Results))
+ }
+ if !resp.Results[0].Installed || resp.Results[0].InstalledName != "pr-review" {
+ t.Fatalf("search result should be treated as installed when directory is occupied, got %#v", resp.Results[0])
+ }
+}
+
+func TestHandleInstallSkillRollsBackOnOriginMetadataWriteFailure(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ zipContent := buildSkillZip(t, map[string]string{
+ "SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n",
+ })
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/skills/github":
+ json.NewEncoder(w).Encode(map[string]any{
+ "slug": "github",
+ "displayName": "GitHub",
+ "summary": "GitHub registry skill",
+ "latestVersion": map[string]any{
+ "version": "1.2.3",
+ },
+ "moderation": map[string]any{
+ "isMalwareBlocked": false,
+ "isSuspicious": false,
+ },
+ })
+ case "/api/v1/download":
+ w.Header().Set("Content-Type", "application/zip")
+ _, _ = w.Write(zipContent)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ previousPersist := persistSkillOriginMeta
+ persistSkillOriginMeta = func(targetDir string, meta installedSkillOriginMeta) error {
+ return errors.New("forced metadata failure")
+ }
+ defer func() {
+ persistSkillOriginMeta = previousPersist
+ }()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ body, err := json.Marshal(installSkillRequest{
+ Slug: "github",
+ Registry: "clawhub",
+ })
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusInternalServerError, rec.Body.String())
+ }
+
+ skillDir := filepath.Join(workspace, "skills", "github")
+ if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
+ t.Fatalf("skill directory should be removed after metadata write failure, stat err=%v", err)
+ }
+}
+
+func TestHandleInstallSkillSerializesConcurrentRequests(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ zipContent := buildSkillZip(t, map[string]string{
+ "SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n",
+ })
+
+ downloadStarted := make(chan struct{}, 2)
+ releaseFirstDownload := make(chan struct{})
+ downloadCount := 0
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/skills/github":
+ json.NewEncoder(w).Encode(map[string]any{
+ "slug": "github",
+ "displayName": "GitHub",
+ "summary": "GitHub registry skill",
+ "latestVersion": map[string]any{
+ "version": "1.2.3",
+ },
+ "moderation": map[string]any{
+ "isMalwareBlocked": false,
+ "isSuspicious": false,
+ },
+ })
+ case "/api/v1/download":
+ downloadCount++
+ downloadStarted <- struct{}{}
+ if downloadCount == 1 {
+ <-releaseFirstDownload
+ }
+ w.Header().Set("Content-Type", "application/zip")
+ _, _ = w.Write(zipContent)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ body, err := json.Marshal(installSkillRequest{
+ Slug: "github",
+ Registry: "clawhub",
+ })
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+
+ type installResult struct {
+ code int
+ body string
+ }
+ results := make(chan installResult, 2)
+ startInstall := func() {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+ results <- installResult{
+ code: rec.Code,
+ body: rec.Body.String(),
+ }
+ }
+
+ go startInstall()
+
+ select {
+ case <-downloadStarted:
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for first install download to start")
+ }
+
+ go startInstall()
+
+ select {
+ case <-downloadStarted:
+ t.Fatal("second install should not reach registry download before the first request completes")
+ case <-time.After(200 * time.Millisecond):
+ }
+
+ close(releaseFirstDownload)
+
+ firstResult := <-results
+ secondResult := <-results
+
+ codes := map[int]int{
+ firstResult.code: 1,
+ secondResult.code: 1,
+ }
+ if codes[http.StatusOK] != 1 || codes[http.StatusConflict] != 1 {
+ t.Fatalf(
+ "unexpected install results: first=(%d, %q) second=(%d, %q)",
+ firstResult.code,
+ firstResult.body,
+ secondResult.code,
+ secondResult.body,
+ )
+ }
+}
+
+func TestHandleImportSkillWaitsForConcurrentInstall(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ zipContent := buildSkillZip(t, map[string]string{
+ "SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n",
+ })
+
+ downloadStarted := make(chan struct{}, 1)
+ releaseDownload := make(chan struct{})
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/skills/github":
+ json.NewEncoder(w).Encode(map[string]any{
+ "slug": "github",
+ "displayName": "GitHub",
+ "summary": "GitHub registry skill",
+ "latestVersion": map[string]any{
+ "version": "1.2.3",
+ },
+ "moderation": map[string]any{
+ "isMalwareBlocked": false,
+ "isSuspicious": false,
+ },
+ })
+ case "/api/v1/download":
+ downloadStarted <- struct{}{}
+ <-releaseDownload
+ w.Header().Set("Content-Type", "application/zip")
+ _, _ = w.Write(zipContent)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ installBody, err := json.Marshal(installSkillRequest{
+ Slug: "github",
+ Registry: "clawhub",
+ })
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+
+ type result struct {
+ code int
+ body string
+ }
+ installResults := make(chan result, 1)
+ importResults := make(chan result, 1)
+
+ go func() {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(installBody))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+ installResults <- result{code: rec.Code, body: rec.Body.String()}
+ }()
+
+ select {
+ case <-downloadStarted:
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for install download to start")
+ }
+
+ var importBody bytes.Buffer
+ writer := multipart.NewWriter(&importBody)
+ part, err := writer.CreateFormFile("file", "github.md")
+ if err != nil {
+ t.Fatalf("CreateFormFile() error = %v", err)
+ }
+ if _, err := io.WriteString(part, "# GitHub\n"); err != nil {
+ t.Fatalf("WriteString() error = %v", err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatalf("Close() error = %v", err)
+ }
+
+ go func() {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &importBody)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ mux.ServeHTTP(rec, req)
+ importResults <- result{code: rec.Code, body: rec.Body.String()}
+ }()
+
+ select {
+ case got := <-importResults:
+ t.Fatalf("import should wait for the install lock, got early response (%d, %q)", got.code, got.body)
+ case <-time.After(200 * time.Millisecond):
+ }
+
+ close(releaseDownload)
+
+ installResult := <-installResults
+ importResult := <-importResults
+
+ if installResult.code != http.StatusOK {
+ t.Fatalf("install status = %d, want %d, body=%s", installResult.code, http.StatusOK, installResult.body)
+ }
+ if importResult.code != http.StatusConflict {
+ t.Fatalf("import status = %d, want %d, body=%s", importResult.code, http.StatusConflict, importResult.body)
+ }
+}
+
+func TestHandleInstallSkillRejectsInvalidArchive(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ zipContent := buildSkillZip(t, map[string]string{
+ "README.md": "# Not a skill\n",
+ })
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/skills/github":
+ json.NewEncoder(w).Encode(map[string]any{
+ "slug": "github",
+ "displayName": "GitHub",
+ "summary": "GitHub registry skill",
+ "latestVersion": map[string]any{
+ "version": "1.2.3",
+ },
+ "moderation": map[string]any{
+ "isMalwareBlocked": false,
+ "isSuspicious": false,
+ },
+ })
+ case "/api/v1/download":
+ w.Header().Set("Content-Type", "application/zip")
+ _, _ = w.Write(zipContent)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ body, err := json.Marshal(installSkillRequest{
+ Slug: "github",
+ Registry: "clawhub",
+ })
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadGateway {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadGateway, rec.Body.String())
+ }
+
+ skillDir := filepath.Join(workspace, "skills", "github")
+ if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
+ t.Fatalf("invalid installed archive should be removed, stat err=%v", err)
+ }
+}
+
+func buildSkillZip(t *testing.T, files map[string]string) []byte {
+ t.Helper()
+
+ var buf bytes.Buffer
+ zipWriter := zip.NewWriter(&buf)
+ for name, content := range files {
+ writer, err := zipWriter.Create(name)
+ if err != nil {
+ t.Fatalf("Create(%q) error = %v", name, err)
+ }
+ if _, err := io.WriteString(writer, content); err != nil {
+ t.Fatalf("WriteString(%q) error = %v", name, err)
+ }
+ }
+ if err := zipWriter.Close(); err != nil {
+ t.Fatalf("Close() error = %v", err)
+ }
+ return buf.Bytes()
+}
diff --git a/web/backend/api/startup.go b/web/backend/api/startup.go
index 1c685bc90..8a3b8e8ff 100644
--- a/web/backend/api/startup.go
+++ b/web/backend/api/startup.go
@@ -90,6 +90,9 @@ func (h *Handler) resolveLaunchCommand() (string, []string, error) {
}
args := []string{"-no-browser"}
+ if h.debug {
+ args = append(args, "-d")
+ }
if h.configPath != "" {
args = append(args, h.configPath)
}
diff --git a/web/backend/api/startup_test.go b/web/backend/api/startup_test.go
index cfa9b4c53..c224d36e2 100644
--- a/web/backend/api/startup_test.go
+++ b/web/backend/api/startup_test.go
@@ -45,6 +45,29 @@ func TestResolveLaunchCommandUsesConfigFileDefaults(t *testing.T) {
}
}
+func TestResolveLaunchCommandIncludesDebugFlagWhenEnabled(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ h.SetDebug(true)
+
+ _, args, err := h.resolveLaunchCommand()
+ if err != nil {
+ t.Fatalf("resolveLaunchCommand() error = %v", err)
+ }
+ if len(args) != 3 {
+ t.Fatalf("args len = %d, want 3 (got %v)", len(args), args)
+ }
+ if args[0] != "-no-browser" {
+ t.Fatalf("args[0] = %q, want %q", args[0], "-no-browser")
+ }
+ if args[1] != "-d" {
+ t.Fatalf("args[1] = %q, want %q", args[1], "-d")
+ }
+ if args[2] != configPath {
+ t.Fatalf("args[2] = %q, want %q", args[2], configPath)
+ }
+}
+
func TestBuildDarwinPlistIncludesRunAtLoad(t *testing.T) {
plist := buildDarwinPlist("/tmp/picoclaw-web", []string{"-no-browser", "/tmp/config.json"})
if !strings.Contains(plist, "RunAtLoad ") {
diff --git a/web/backend/api/tools.go b/web/backend/api/tools.go
index 9df4a7091..3476e3c53 100644
--- a/web/backend/api/tools.go
+++ b/web/backend/api/tools.go
@@ -5,8 +5,10 @@ import (
"fmt"
"net/http"
"runtime"
+ "strings"
"github.com/sipeed/picoclaw/pkg/config"
+ picotools "github.com/sipeed/picoclaw/pkg/tools"
)
type toolCatalogEntry struct {
@@ -33,6 +35,39 @@ type toolStateRequest struct {
Enabled bool `json:"enabled"`
}
+type webSearchProviderOption struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ Configured bool `json:"configured"`
+ Current bool `json:"current"`
+ RequiresAuth bool `json:"requires_auth"`
+}
+
+type webSearchProviderConfig struct {
+ Enabled bool `json:"enabled"`
+ MaxResults int `json:"max_results"`
+ BaseURL string `json:"base_url,omitempty"`
+ APIKey string `json:"api_key,omitempty"`
+ APIKeys []string `json:"api_keys,omitempty"`
+ APIKeySet bool `json:"api_key_set,omitempty"`
+}
+
+type webSearchConfigResponse struct {
+ Provider string `json:"provider"`
+ CurrentService string `json:"current_service"`
+ PreferNative bool `json:"prefer_native"`
+ Proxy string `json:"proxy,omitempty"`
+ Providers []webSearchProviderOption `json:"providers"`
+ Settings map[string]webSearchProviderConfig `json:"settings"`
+}
+
+type webSearchConfigRequest struct {
+ Provider string `json:"provider"`
+ PreferNative bool `json:"prefer_native"`
+ Proxy string `json:"proxy"`
+ Settings map[string]webSearchProviderConfig `json:"settings"`
+}
+
var toolCatalog = []toolCatalogEntry{
{
Name: "read_file",
@@ -136,6 +171,12 @@ var toolCatalog = []toolCatalogEntry{
Category: "hardware",
ConfigKey: "spi",
},
+ {
+ Name: "serial",
+ Description: "Interact with serial ports exposed on the host.",
+ Category: "hardware",
+ ConfigKey: "serial",
+ },
{
Name: "tool_search_tool_regex",
Description: "Discover hidden MCP tools by regex search when tool discovery is enabled.",
@@ -153,6 +194,8 @@ var toolCatalog = []toolCatalogEntry{
func (h *Handler) registerToolRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/tools", h.handleListTools)
mux.HandleFunc("PUT /api/tools/{name}/state", h.handleUpdateToolState)
+ mux.HandleFunc("GET /api/tools/web-search-config", h.handleGetWebSearchConfig)
+ mux.HandleFunc("PUT /api/tools/web-search-config", h.handleUpdateWebSearchConfig)
}
func (h *Handler) handleListTools(w http.ResponseWriter, r *http.Request) {
@@ -224,8 +267,12 @@ func buildToolSupport(cfg *config.Config) []toolSupportItem {
status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseRegex)
case "tool_search_tool_bm25":
status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseBM25)
+ case "web_search":
+ status, reasonCode = resolveWebSearchToolSupport(cfg)
case "i2c", "spi":
status, reasonCode = resolveHardwareToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey))
+ case "serial":
+ status, reasonCode = resolveSerialToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey))
default:
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
status = "enabled"
@@ -254,6 +301,18 @@ func resolveHardwareToolSupport(enabled bool) (string, string) {
return "enabled", ""
}
+func resolveSerialToolSupport(enabled bool) (string, string) {
+ if !enabled {
+ return "disabled", ""
+ }
+ switch runtime.GOOS {
+ case "linux", "darwin", "windows":
+ return "enabled", ""
+ default:
+ return "blocked", "requires_serial_platform"
+ }
+}
+
func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string, string) {
if !cfg.Tools.IsToolEnabled("mcp") {
return "disabled", ""
@@ -267,6 +326,13 @@ func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string
return "enabled", ""
}
+func resolveWebSearchToolSupport(cfg *config.Config) (string, string) {
+ if !cfg.Tools.IsToolEnabled("web") {
+ return "disabled", ""
+ }
+ return "enabled", ""
+}
+
func applyToolState(cfg *config.Config, toolName string, enabled bool) error {
switch toolName {
case "read_file":
@@ -316,6 +382,8 @@ func applyToolState(cfg *config.Config, toolName string, enabled bool) error {
cfg.Tools.I2C.Enabled = enabled
case "spi":
cfg.Tools.SPI.Enabled = enabled
+ case "serial":
+ cfg.Tools.Serial.Enabled = enabled
case "tool_search_tool_regex":
cfg.Tools.MCP.Discovery.UseRegex = enabled
if enabled {
@@ -333,3 +401,274 @@ func applyToolState(cfg *config.Config, toolName string, enabled bool) error {
}
return nil
}
+
+func (h *Handler) handleGetWebSearchConfig(w http.ResponseWriter, r *http.Request) {
+ cfg, err := config.LoadConfig(h.configPath)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(buildWebSearchConfigResponse(cfg)); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ }
+}
+
+func (h *Handler) handleUpdateWebSearchConfig(w http.ResponseWriter, r *http.Request) {
+ cfg, err := config.LoadConfig(h.configPath)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ var req webSearchConfigRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ provider := normalizeWebSearchProvider(req.Provider)
+ if provider == "" {
+ http.Error(w, "invalid web search provider", http.StatusBadRequest)
+ return
+ }
+
+ cfg.Tools.Web.Provider = provider
+ cfg.Tools.Web.PreferNative = req.PreferNative
+ cfg.Tools.Web.Proxy = strings.TrimSpace(req.Proxy)
+
+ if settings, ok := req.Settings["sogou"]; ok {
+ cfg.Tools.Web.Sogou.Enabled = settings.Enabled
+ cfg.Tools.Web.Sogou.MaxResults = settings.MaxResults
+ }
+ if settings, ok := req.Settings["duckduckgo"]; ok {
+ cfg.Tools.Web.DuckDuckGo.Enabled = settings.Enabled
+ cfg.Tools.Web.DuckDuckGo.MaxResults = settings.MaxResults
+ }
+ if settings, ok := req.Settings["brave"]; ok {
+ cfg.Tools.Web.Brave.Enabled = settings.Enabled
+ cfg.Tools.Web.Brave.MaxResults = settings.MaxResults
+ if keys, ok := normalizeWebSearchAPIKeys(settings.APIKeys, settings.APIKey); ok {
+ cfg.Tools.Web.Brave.SetAPIKeys(keys)
+ }
+ }
+ if settings, ok := req.Settings["tavily"]; ok {
+ cfg.Tools.Web.Tavily.Enabled = settings.Enabled
+ cfg.Tools.Web.Tavily.MaxResults = settings.MaxResults
+ cfg.Tools.Web.Tavily.BaseURL = strings.TrimSpace(settings.BaseURL)
+ if keys, ok := normalizeWebSearchAPIKeys(settings.APIKeys, settings.APIKey); ok {
+ cfg.Tools.Web.Tavily.SetAPIKeys(keys)
+ }
+ }
+ if settings, ok := req.Settings["perplexity"]; ok {
+ cfg.Tools.Web.Perplexity.Enabled = settings.Enabled
+ cfg.Tools.Web.Perplexity.MaxResults = settings.MaxResults
+ if keys, ok := normalizeWebSearchAPIKeys(settings.APIKeys, settings.APIKey); ok {
+ cfg.Tools.Web.Perplexity.APIKeys = config.SimpleSecureStrings(keys...)
+ }
+ }
+ if settings, ok := req.Settings["searxng"]; ok {
+ cfg.Tools.Web.SearXNG.Enabled = settings.Enabled
+ cfg.Tools.Web.SearXNG.MaxResults = settings.MaxResults
+ cfg.Tools.Web.SearXNG.BaseURL = strings.TrimSpace(settings.BaseURL)
+ }
+ if settings, ok := req.Settings["glm_search"]; ok {
+ cfg.Tools.Web.GLMSearch.Enabled = settings.Enabled
+ cfg.Tools.Web.GLMSearch.MaxResults = settings.MaxResults
+ cfg.Tools.Web.GLMSearch.BaseURL = strings.TrimSpace(settings.BaseURL)
+ if key := strings.TrimSpace(settings.APIKey); key != "" {
+ cfg.Tools.Web.GLMSearch.APIKey = *config.NewSecureString(key)
+ }
+ }
+ if settings, ok := req.Settings["baidu_search"]; ok {
+ cfg.Tools.Web.BaiduSearch.Enabled = settings.Enabled
+ cfg.Tools.Web.BaiduSearch.MaxResults = settings.MaxResults
+ cfg.Tools.Web.BaiduSearch.BaseURL = strings.TrimSpace(settings.BaseURL)
+ if key := strings.TrimSpace(settings.APIKey); key != "" {
+ cfg.Tools.Web.BaiduSearch.APIKey = *config.NewSecureString(key)
+ }
+ }
+
+ if err := config.SaveConfig(h.configPath, cfg); err != nil {
+ http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(buildWebSearchConfigResponse(cfg)); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ }
+}
+
+func normalizeWebSearchProvider(provider string) string {
+ switch strings.ToLower(strings.TrimSpace(provider)) {
+ case "", "auto":
+ return "auto"
+ case "sogou", "brave", "tavily", "duckduckgo", "perplexity", "searxng", "glm_search", "baidu_search":
+ return strings.ToLower(strings.TrimSpace(provider))
+ default:
+ return ""
+ }
+}
+
+func normalizeWebSearchAPIKeys(apiKeys []string, apiKey string) ([]string, bool) {
+ if apiKeys != nil {
+ keys := make([]string, 0, len(apiKeys))
+ seen := make(map[string]struct{}, len(apiKeys))
+ for _, key := range apiKeys {
+ trimmed := strings.TrimSpace(key)
+ if trimmed == "" {
+ continue
+ }
+ if _, ok := seen[trimmed]; ok {
+ continue
+ }
+ seen[trimmed] = struct{}{}
+ keys = append(keys, trimmed)
+ }
+ return keys, true
+ }
+
+ if trimmed := strings.TrimSpace(apiKey); trimmed != "" {
+ return []string{trimmed}, true
+ }
+
+ return nil, false
+}
+
+func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse {
+ opts := picotools.WebSearchToolOptionsFromConfig(cfg)
+ current := resolveCurrentWebSearchProvider(cfg)
+ settings := map[string]webSearchProviderConfig{
+ "sogou": {
+ Enabled: cfg.Tools.Web.Sogou.Enabled,
+ MaxResults: cfg.Tools.Web.Sogou.MaxResults,
+ },
+ "duckduckgo": {
+ Enabled: cfg.Tools.Web.DuckDuckGo.Enabled,
+ MaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
+ },
+ "brave": {
+ Enabled: cfg.Tools.Web.Brave.Enabled,
+ MaxResults: cfg.Tools.Web.Brave.MaxResults,
+ APIKeySet: len(cfg.Tools.Web.Brave.APIKeys.Values()) > 0,
+ },
+ "tavily": {
+ Enabled: cfg.Tools.Web.Tavily.Enabled,
+ MaxResults: cfg.Tools.Web.Tavily.MaxResults,
+ BaseURL: cfg.Tools.Web.Tavily.BaseURL,
+ APIKeySet: len(cfg.Tools.Web.Tavily.APIKeys.Values()) > 0,
+ },
+ "perplexity": {
+ Enabled: cfg.Tools.Web.Perplexity.Enabled,
+ MaxResults: cfg.Tools.Web.Perplexity.MaxResults,
+ APIKeySet: len(cfg.Tools.Web.Perplexity.APIKeys.Values()) > 0,
+ },
+ "searxng": {
+ Enabled: cfg.Tools.Web.SearXNG.Enabled,
+ MaxResults: cfg.Tools.Web.SearXNG.MaxResults,
+ BaseURL: cfg.Tools.Web.SearXNG.BaseURL,
+ },
+ "glm_search": {
+ Enabled: cfg.Tools.Web.GLMSearch.Enabled,
+ MaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
+ BaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
+ APIKeySet: cfg.Tools.Web.GLMSearch.APIKey.String() != "",
+ },
+ "baidu_search": {
+ Enabled: cfg.Tools.Web.BaiduSearch.Enabled,
+ MaxResults: cfg.Tools.Web.BaiduSearch.MaxResults,
+ BaseURL: cfg.Tools.Web.BaiduSearch.BaseURL,
+ APIKeySet: cfg.Tools.Web.BaiduSearch.APIKey.String() != "",
+ },
+ }
+
+ providers := []webSearchProviderOption{
+ {
+ ID: "auto",
+ Label: "Auto",
+ Configured: current != "",
+ Current: cfg.Tools.Web.Provider == "" ||
+ cfg.Tools.Web.Provider == "auto",
+ },
+ {
+ ID: "sogou",
+ Label: "Sogou",
+ Configured: picotools.WebSearchProviderReady(opts, "sogou"),
+ Current: current == "sogou",
+ },
+ {
+ ID: "duckduckgo",
+ Label: "DuckDuckGo",
+ Configured: picotools.WebSearchProviderReady(opts, "duckduckgo"),
+ Current: current == "duckduckgo",
+ },
+ {
+ ID: "brave",
+ Label: "Brave Search",
+ Configured: picotools.WebSearchProviderReady(opts, "brave"),
+ Current: current == "brave",
+ RequiresAuth: true,
+ },
+ {
+ ID: "tavily",
+ Label: "Tavily",
+ Configured: picotools.WebSearchProviderReady(opts, "tavily"),
+ Current: current == "tavily",
+ RequiresAuth: true,
+ },
+ {
+ ID: "perplexity",
+ Label: "Perplexity",
+ Configured: picotools.WebSearchProviderReady(opts, "perplexity"),
+ Current: current == "perplexity",
+ RequiresAuth: true,
+ },
+ {
+ ID: "searxng",
+ Label: "SearXNG",
+ Configured: picotools.WebSearchProviderReady(opts, "searxng"),
+ Current: current == "searxng",
+ },
+ {
+ ID: "glm_search",
+ Label: "GLM Search",
+ Configured: picotools.WebSearchProviderReady(opts, "glm_search"),
+ Current: current == "glm_search",
+ RequiresAuth: true,
+ },
+ {
+ ID: "baidu_search",
+ Label: "Baidu Search",
+ Configured: picotools.WebSearchProviderReady(opts, "baidu_search"),
+ Current: current == "baidu_search",
+ RequiresAuth: true,
+ },
+ }
+
+ provider := cfg.Tools.Web.Provider
+ if provider == "" {
+ provider = "auto"
+ }
+
+ return webSearchConfigResponse{
+ Provider: provider,
+ CurrentService: current,
+ PreferNative: cfg.Tools.Web.PreferNative,
+ Proxy: cfg.Tools.Web.Proxy,
+ Providers: providers,
+ Settings: settings,
+ }
+}
+
+func resolveCurrentWebSearchProvider(cfg *config.Config) string {
+ if cfg == nil || !cfg.Tools.IsToolEnabled("web") {
+ return ""
+ }
+ selected, err := picotools.ResolveWebSearchProviderName(picotools.WebSearchToolOptionsFromConfig(cfg), "")
+ if err != nil {
+ return ""
+ }
+ return selected
+}
diff --git a/web/backend/api/tools_test.go b/web/backend/api/tools_test.go
index 646cefbe2..a09a49fd6 100644
--- a/web/backend/api/tools_test.go
+++ b/web/backend/api/tools_test.go
@@ -92,9 +92,36 @@ func TestHandleListTools(t *testing.T) {
if gotTools["i2c"].Status != "disabled" {
t.Fatalf("i2c status = %q, want disabled on linux when config is off", gotTools["i2c"].Status)
}
+ if gotTools["serial"].Status != "disabled" {
+ t.Fatalf("serial status = %q, want disabled when config is off", gotTools["serial"].Status)
+ }
+
+ cfg.Tools.Serial.Enabled = true
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, "/api/tools", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ gotTools = make(map[string]toolSupportItem, len(resp.Tools))
+ for _, tool := range resp.Tools {
+ gotTools[tool.Name] = tool
+ }
+ if gotTools["serial"].Status != "enabled" {
+ t.Fatalf("serial = %#v, want enabled on linux when config is on", gotTools["serial"])
+ }
} else {
cfg.Tools.I2C.Enabled = true
cfg.Tools.SPI.Enabled = true
+ cfg.Tools.Serial.Enabled = true
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
@@ -120,6 +147,16 @@ func TestHandleListTools(t *testing.T) {
if gotTools["spi"].Status != "blocked" || gotTools["spi"].ReasonCode != "requires_linux" {
t.Fatalf("spi = %#v, want blocked/requires_linux", gotTools["spi"])
}
+ switch runtime.GOOS {
+ case "darwin", "windows":
+ if gotTools["serial"].Status != "enabled" {
+ t.Fatalf("serial = %#v, want enabled on supported host", gotTools["serial"])
+ }
+ default:
+ if gotTools["serial"].Status != "blocked" || gotTools["serial"].ReasonCode != "requires_serial_platform" {
+ t.Fatalf("serial = %#v, want blocked/requires_serial_platform", gotTools["serial"])
+ }
+ }
}
}
@@ -195,4 +232,373 @@ func TestHandleUpdateToolState(t *testing.T) {
if !updated.Tools.Cron.Enabled {
t.Fatalf("cron should be enabled: %#v", updated.Tools.Cron)
}
+
+ rec4 := httptest.NewRecorder()
+ req4 := httptest.NewRequest(
+ http.MethodPut,
+ "/api/tools/serial/state",
+ bytes.NewBufferString(`{"enabled":true}`),
+ )
+ req4.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec4, req4)
+ if rec4.Code != http.StatusOK {
+ t.Fatalf("serial status = %d, want %d, body=%s", rec4.Code, http.StatusOK, rec4.Body.String())
+ }
+
+ updated, err = config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig(updated serial) error = %v", err)
+ }
+ if !updated.Tools.Serial.Enabled {
+ t.Fatalf("serial should be enabled: %#v", updated.Tools.Serial)
+ }
+}
+
+func TestHandleListTools_ReportsWebSearchEnabledWhenToolIsOn(t *testing.T) {
+ tests := []struct {
+ name string
+ preferNative bool
+ }{
+ {name: "without prefer_native", preferNative: false},
+ {name: "with prefer_native", preferNative: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.Tools.Web.PreferNative = tt.preferNative
+ cfg.Tools.Web.Provider = "brave"
+ cfg.Tools.Web.Sogou.Enabled = false
+ cfg.Tools.Web.DuckDuckGo.Enabled = false
+ cfg.Tools.Web.Brave.Enabled = true
+ cfg.Tools.Web.Brave.SetAPIKeys(nil)
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/tools", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp toolSupportResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ for _, tool := range resp.Tools {
+ if tool.Name != "web_search" {
+ continue
+ }
+ if tool.Status != "enabled" || tool.ReasonCode != "" {
+ t.Fatalf("web_search = %#v, want enabled with no reason code", tool)
+ }
+ return
+ }
+
+ t.Fatal("expected web_search in response")
+ })
+ }
+}
+
+func TestHandleGetWebSearchConfig(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.Tools.Web.PreferNative = false
+ cfg.Tools.Web.Provider = "sogou"
+ cfg.Tools.Web.Sogou.Enabled = true
+ cfg.Tools.Web.Sogou.MaxResults = 6
+ cfg.Tools.Web.Brave.Enabled = true
+ cfg.Tools.Web.Brave.SetAPIKey("brave-test-key")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/tools/web-search-config", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp webSearchConfigResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if resp.Provider != "sogou" {
+ t.Fatalf("provider = %q, want sogou", resp.Provider)
+ }
+ if resp.CurrentService != "sogou" {
+ t.Fatalf("current_service = %q, want sogou", resp.CurrentService)
+ }
+ if !resp.Settings["brave"].APIKeySet {
+ t.Fatalf("brave api_key_set should be true: %#v", resp.Settings["brave"])
+ }
+}
+
+func TestHandleGetWebSearchConfig_DoesNotExposeNativeAsCurrentService(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.Tools.Web.PreferNative = true
+ cfg.Tools.Web.Provider = "brave"
+ cfg.Tools.Web.Sogou.Enabled = false
+ cfg.Tools.Web.DuckDuckGo.Enabled = false
+ cfg.Tools.Web.Brave.Enabled = true
+ cfg.Tools.Web.Brave.SetAPIKeys(nil)
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/tools/web-search-config", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp webSearchConfigResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if !resp.PreferNative {
+ t.Fatal("prefer_native should remain true in response")
+ }
+ if resp.CurrentService != "" {
+ t.Fatalf("current_service = %q, want empty when no external provider is ready", resp.CurrentService)
+ }
+}
+
+func TestHandleUpdateWebSearchConfig(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.Tools.Web.Brave.SetAPIKeys([]string{"brave-old-1", "brave-old-2"})
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(
+ http.MethodPut,
+ "/api/tools/web-search-config",
+ bytes.NewBufferString(`{
+ "provider":"brave",
+ "prefer_native":false,
+ "proxy":"http://127.0.0.1:7890",
+ "settings":{
+ "sogou":{"enabled":true,"max_results":4},
+ "brave":{"enabled":true,"max_results":7,"api_key":"brave-new-key"},
+ "duckduckgo":{"enabled":false,"max_results":3}
+ }
+ }`),
+ )
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if updated.Tools.Web.Provider != "brave" {
+ t.Fatalf("provider = %q, want brave", updated.Tools.Web.Provider)
+ }
+ if updated.Tools.Web.PreferNative {
+ t.Fatal("prefer_native should be false after update")
+ }
+ if updated.Tools.Web.Proxy != "http://127.0.0.1:7890" {
+ t.Fatalf("proxy = %q", updated.Tools.Web.Proxy)
+ }
+ if !updated.Tools.Web.Sogou.Enabled || updated.Tools.Web.Sogou.MaxResults != 4 {
+ t.Fatalf("sogou config not updated: %#v", updated.Tools.Web.Sogou)
+ }
+ if !updated.Tools.Web.Brave.Enabled || updated.Tools.Web.Brave.MaxResults != 7 {
+ t.Fatalf("brave config not updated: %#v", updated.Tools.Web.Brave)
+ }
+ if updated.Tools.Web.Brave.APIKey() != "brave-new-key" {
+ t.Fatalf("brave api key not updated")
+ }
+}
+
+func TestHandleUpdateWebSearchConfig_PreservesAndReplacesMultiKeys(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.Tools.Web.Brave.SetAPIKeys([]string{"brave-old-1", "brave-old-2"})
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(
+ http.MethodPut,
+ "/api/tools/web-search-config",
+ bytes.NewBufferString(`{
+ "provider":"auto",
+ "prefer_native":true,
+ "proxy":"",
+ "settings":{
+ "brave":{"enabled":true,"max_results":7}
+ }
+ }`),
+ )
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.Tools.Web.Brave.APIKeys.Values(); len(got) != 2 ||
+ got[0] != "brave-old-1" || got[1] != "brave-old-2" {
+ t.Fatalf("brave api keys should be preserved, got %#v", got)
+ }
+
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(
+ http.MethodPut,
+ "/api/tools/web-search-config",
+ bytes.NewBufferString(`{
+ "provider":"auto",
+ "prefer_native":true,
+ "proxy":"",
+ "settings":{
+ "brave":{"enabled":true,"max_results":7,"api_keys":["brave-new-1","brave-new-2","brave-new-1"]}
+ }
+ }`),
+ )
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err = config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.Tools.Web.Brave.APIKeys.Values(); len(got) != 2 ||
+ got[0] != "brave-new-1" || got[1] != "brave-new-2" {
+ t.Fatalf("brave api keys should be replaced by api_keys, got %#v", got)
+ }
+}
+
+func TestResolveCurrentWebSearchProvider_PrefersConfiguredProvidersBeforeSogou(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Tools.Web.Provider = "auto"
+ cfg.Tools.Web.Sogou.Enabled = true
+ cfg.Tools.Web.Brave.Enabled = true
+ cfg.Tools.Web.Brave.SetAPIKey("brave-test-key")
+
+ if got := resolveCurrentWebSearchProvider(cfg); got != "brave" {
+ t.Fatalf("resolveCurrentWebSearchProvider() = %q, want brave", got)
+ }
+}
+
+func TestResolveCurrentWebSearchProvider_FallsBackWhenExplicitProviderUnavailable(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Tools.Web.Provider = "brave"
+ cfg.Tools.Web.Brave.Enabled = true
+ cfg.Tools.Web.Sogou.Enabled = true
+
+ if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" {
+ t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got)
+ }
+}
+
+func TestResolveCurrentWebSearchProvider_FallsBackWhenProviderIsUnknown(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Tools.Web.Provider = "totally_unknown"
+ cfg.Tools.Web.Sogou.Enabled = true
+
+ if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" {
+ t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got)
+ }
+}
+
+func TestResolveCurrentWebSearchProvider_PrefersStableDefaultForSogouAndDuckDuckGo(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Tools.Web.Provider = "auto"
+ cfg.Tools.Web.Sogou.Enabled = true
+ cfg.Tools.Web.DuckDuckGo.Enabled = true
+
+ if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" {
+ t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got)
+ }
+}
+
+func TestResolveCurrentWebSearchProvider_IgnoresPreferNativeInConfigView(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "custom-default",
+ Model: "openai/gpt-4o",
+ APIKeys: config.SimpleSecureStrings("sk-default"),
+ }}
+ cfg.Agents.Defaults.ModelName = "custom-default"
+ cfg.Tools.Web.PreferNative = true
+ cfg.Tools.Web.Provider = "brave"
+ cfg.Tools.Web.Sogou.Enabled = false
+ cfg.Tools.Web.DuckDuckGo.Enabled = false
+ cfg.Tools.Web.Brave.Enabled = true
+
+ if got := resolveCurrentWebSearchProvider(cfg); got != "" {
+ t.Fatalf("resolveCurrentWebSearchProvider() = %q, want empty when only native search would be available", got)
+ }
}
diff --git a/web/backend/api/update.go b/web/backend/api/update.go
new file mode 100644
index 000000000..2ba862631
--- /dev/null
+++ b/web/backend/api/update.go
@@ -0,0 +1,52 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/sipeed/picoclaw/pkg/updater"
+)
+
+// registerUpdateRoutes registers the self-update endpoint.
+func (h *Handler) registerUpdateRoutes(mux *http.ServeMux) {
+ mux.HandleFunc("/api/update", h.handleUpdate)
+}
+
+type updateRequest struct {
+ URL string `json:"url,omitempty"`
+ Binary string `json:"binary,omitempty"`
+}
+
+type updateResponse struct {
+ Status string `json:"status"`
+ Message string `json:"message,omitempty"`
+}
+
+func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ _ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: "method not allowed"})
+ return
+ }
+
+ dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
+ var req updateRequest
+ if err := dec.Decode(&req); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: "invalid request body"})
+ return
+ }
+
+ binary := req.Binary
+ if binary == "" {
+ binary = "picoclaw-launcher"
+ }
+
+ if err := updater.UpdateSelfFromRelease(req.URL, "", "", binary); err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ _ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: err.Error()})
+ return
+ }
+
+ _ = json.NewEncoder(w).Encode(updateResponse{Status: "ok", Message: "update applied; restart to use new version"})
+}
diff --git a/web/backend/api/version.go b/web/backend/api/version.go
new file mode 100644
index 000000000..6232b989b
--- /dev/null
+++ b/web/backend/api/version.go
@@ -0,0 +1,345 @@
+package api
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os/exec"
+ "regexp"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/web/backend/utils"
+)
+
+type systemVersionResponse struct {
+ Version string `json:"version"`
+ GitCommit string `json:"git_commit,omitempty"`
+ BuildTime string `json:"build_time,omitempty"`
+ GoVersion string `json:"go_version"`
+}
+
+type cachedSystemVersion struct {
+ value systemVersionResponse
+ gatewayPID int
+}
+
+type systemVersionCache struct {
+ mu sync.Mutex
+ current cachedSystemVersion
+ hasCurrent bool
+ inflightCh chan struct{}
+}
+
+func newSystemVersionCache() *systemVersionCache {
+ return &systemVersionCache{}
+}
+
+var (
+ // 15 seconds matches the gateway startup window used elsewhere in launcher flow,
+ // giving slow/embedded hosts enough time for first command invocation while
+ // staying independent from cross-file init ordering.
+ versionCmdTimeout = 15 * time.Second
+ maxVersionResolveAttempts = 3
+ findPicoclawBinaryForInfo = resolveGatewayBinaryForVersionInfo
+ runPicoclawVersionOutput = executePicoclawVersion
+ currentGatewayVersionState = gatewayVersionState
+ launcherBuildInfoForVersion = fallbackSystemVersionInfoFromConfig
+ versionInfoCache = newSystemVersionCache()
+ ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)
+ versionLinePattern = regexp.MustCompile(
+ `^(?:[^A-Za-z0-9]*\s*)?picoclaw(?:\.exe)?\s+([^\s(]+)` +
+ `(?:\s+\(git:\s*([^)]+)\))?\s*$`,
+ )
+)
+
+func (h *Handler) registerVersionRoutes(mux *http.ServeMux) {
+ mux.HandleFunc("GET /api/system/version", h.handleGetVersion)
+}
+
+// handleGetVersion returns runtime version information for web clients.
+func (h *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
+ versionInfo := h.resolveSystemVersionInfo(r.Context())
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(versionInfo); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ return
+ }
+}
+
+// resolveSystemVersionInfo prefers the actual picoclaw binary version output,
+// and falls back to launcher build metadata when command execution fails.
+func (h *Handler) resolveSystemVersionInfo(ctx context.Context) systemVersionResponse {
+ for range maxVersionResolveAttempts {
+ gatewayPID, gatewayAlive := currentGatewayVersionState()
+ if cached, ok := versionInfoCache.get(gatewayPID, gatewayAlive); ok {
+ return cached
+ }
+
+ leader, ok := versionInfoCache.waitOrStart(ctx)
+ if !ok {
+ return fallbackSystemVersionInfo()
+ }
+ if !leader {
+ continue
+ }
+
+ resolved := h.resolveSystemVersionInfoUncached(ctx)
+ gatewayPID, gatewayAlive = currentGatewayVersionState()
+ versionInfoCache.finishResolve(resolved, gatewayPID, gatewayAlive)
+ return resolved
+ }
+
+ return fallbackSystemVersionInfo()
+}
+
+func (h *Handler) resolveSystemVersionInfoUncached(ctx context.Context) systemVersionResponse {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ fallback := fallbackSystemVersionInfo()
+
+ execPath := strings.TrimSpace(findPicoclawBinaryForInfo())
+ if execPath == "" {
+ return fallback
+ }
+
+ cmdCtx, cancel := context.WithTimeout(ctx, versionCmdTimeout)
+ defer cancel()
+
+ output, err := runPicoclawVersionOutput(cmdCtx, execPath)
+ if err != nil {
+ return fallback
+ }
+
+ parsed, ok := parsePicoclawVersionOutput(output)
+ if !ok {
+ return fallback
+ }
+
+ if parsed.GoVersion == "" {
+ parsed.GoVersion = fallback.GoVersion
+ if parsed.GoVersion == "" {
+ parsed.GoVersion = runtime.Version()
+ }
+ }
+
+ return parsed
+}
+
+func fallbackSystemVersionInfo() systemVersionResponse {
+ return launcherBuildInfoForVersion()
+}
+
+func fallbackSystemVersionInfoFromConfig() systemVersionResponse {
+ buildTime, goVer := config.FormatBuildInfo()
+ return systemVersionResponse{
+ Version: config.GetVersion(),
+ GitCommit: config.GitCommit,
+ BuildTime: buildTime,
+ GoVersion: goVer,
+ }
+}
+
+// resolveGatewayBinaryForVersionInfo uses the same executable as the launcher
+// gateway start path when available, then falls back to launcher binary lookup.
+// This keeps version probing aligned with the actual gateway startup behavior,
+// so web and gateway do not drift onto different binaries.
+func resolveGatewayBinaryForVersionInfo() string {
+ gateway.mu.Lock()
+ cmd := gateway.cmd
+ gateway.mu.Unlock()
+
+ if cmd != nil {
+ if execPath := strings.TrimSpace(cmd.Path); execPath != "" {
+ return execPath
+ }
+ }
+
+ return utils.FindPicoclawBinary()
+}
+
+func gatewayVersionState() (int, bool) {
+ gateway.mu.Lock()
+ defer gateway.mu.Unlock()
+
+ if gateway.cmd == nil || gateway.cmd.Process == nil {
+ return 0, false
+ }
+ pid := gateway.cmd.Process.Pid
+ if pid <= 0 {
+ return 0, false
+ }
+
+ return pid, isCmdProcessAliveLocked(gateway.cmd)
+}
+
+func (c *systemVersionCache) get(gatewayPID int, gatewayAlive bool) (systemVersionResponse, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if c.hasCurrent && (!gatewayAlive || gatewayPID <= 0 || gatewayPID != c.current.gatewayPID) {
+ c.clearCurrentLocked()
+ }
+
+ if c.hasCurrent {
+ return c.current.value, true
+ }
+
+ return systemVersionResponse{}, false
+}
+
+func (c *systemVersionCache) waitOrStart(ctx context.Context) (bool, bool) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if ctx.Err() != nil {
+ return false, false
+ }
+
+ c.mu.Lock()
+ if c.inflightCh == nil {
+ c.inflightCh = make(chan struct{})
+ c.mu.Unlock()
+ return true, true
+ }
+ waitCh := c.inflightCh
+ c.mu.Unlock()
+
+ select {
+ case <-waitCh:
+ return false, true
+ case <-ctx.Done():
+ return false, false
+ }
+}
+
+func (c *systemVersionCache) finishResolve(value systemVersionResponse, gatewayPID int, gatewayAlive bool) {
+ c.mu.Lock()
+ if gatewayAlive && gatewayPID > 0 {
+ c.current = cachedSystemVersion{value: value, gatewayPID: gatewayPID}
+ c.hasCurrent = true
+ } else {
+ c.clearCurrentLocked()
+ }
+
+ inflightCh := c.inflightCh
+ c.inflightCh = nil
+ c.mu.Unlock()
+
+ if inflightCh != nil {
+ close(inflightCh)
+ }
+}
+
+func (c *systemVersionCache) clearCurrentLocked() {
+ c.hasCurrent = false
+ c.current = cachedSystemVersion{}
+}
+
+func (c *systemVersionCache) resetForTest() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ c.current = cachedSystemVersion{}
+ c.hasCurrent = false
+ if c.inflightCh != nil {
+ close(c.inflightCh)
+ c.inflightCh = nil
+ }
+}
+
+// executePicoclawVersion runs the version subcommand against the
+// discovered picoclaw executable.
+func executePicoclawVersion(ctx context.Context, execPath string) (string, error) {
+ out, err := exec.CommandContext(ctx, execPath, "version").CombinedOutput()
+ if err == nil {
+ return string(out), nil
+ }
+
+ return string(out), fmt.Errorf("failed to execute version command: %w", err)
+}
+
+// parsePicoclawVersionOutput extracts version/build/go fields from CLI output.
+// It accepts banner/ANSI-decorated output and only requires the version line.
+func parsePicoclawVersionOutput(raw string) (systemVersionResponse, bool) {
+ var result systemVersionResponse
+
+ scanner := bufio.NewScanner(strings.NewReader(raw))
+ for scanner.Scan() {
+ line := strings.TrimSpace(ansiEscapePattern.ReplaceAllString(scanner.Text(), ""))
+ if line == "" {
+ continue
+ }
+
+ if match := versionLinePattern.FindStringSubmatch(line); len(match) > 0 {
+ candidateVersion := strings.TrimSpace(match[1])
+ if !isLikelyVersionValue(candidateVersion) {
+ continue
+ }
+ result.Version = candidateVersion
+ if len(match) > 2 {
+ result.GitCommit = strings.TrimSpace(match[2])
+ }
+ continue
+ }
+
+ if buildValue, ok := strings.CutPrefix(line, "Build:"); ok {
+ result.BuildTime = strings.TrimSpace(buildValue)
+ continue
+ }
+
+ if goValue, ok := strings.CutPrefix(line, "Go:"); ok {
+ result.GoVersion = strings.TrimSpace(goValue)
+ }
+ }
+
+ if err := scanner.Err(); err != nil {
+ return systemVersionResponse{}, false
+ }
+
+ if result.Version == "" {
+ return systemVersionResponse{}, false
+ }
+
+ return result, true
+}
+
+func isLikelyVersionValue(value string) bool {
+ v := strings.TrimSpace(strings.ToLower(value))
+ if v == "" {
+ return false
+ }
+ if v == "dev" {
+ return true
+ }
+
+ // Accept git-like short/long hashes even when they contain only letters (a-f).
+ if len(v) >= 7 && len(v) <= 40 {
+ allHex := true
+ for _, ch := range v {
+ if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') {
+ continue
+ }
+ allHex = false
+ break
+ }
+ if allHex {
+ return true
+ }
+ }
+
+ for _, ch := range v {
+ if ch >= '0' && ch <= '9' {
+ return true
+ }
+ }
+ return false
+}
diff --git a/web/backend/api/version_test.go b/web/backend/api/version_test.go
new file mode 100644
index 000000000..31c5366ab
--- /dev/null
+++ b/web/backend/api/version_test.go
@@ -0,0 +1,317 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os/exec"
+ "runtime"
+ "testing"
+)
+
+func setupVersionTestIsolation(t *testing.T) {
+ t.Helper()
+
+ originalGatewayState := currentGatewayVersionState
+ originalFinder := findPicoclawBinaryForInfo
+ originalRunner := runPicoclawVersionOutput
+ originalFallback := launcherBuildInfoForVersion
+ t.Cleanup(func() {
+ currentGatewayVersionState = originalGatewayState
+ findPicoclawBinaryForInfo = originalFinder
+ runPicoclawVersionOutput = originalRunner
+ launcherBuildInfoForVersion = originalFallback
+ versionInfoCache.resetForTest()
+ })
+
+ currentGatewayVersionState = func() (int, bool) { return 0, false }
+ versionInfoCache.resetForTest()
+}
+
+func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ launcherBuildInfoForVersion = func() systemVersionResponse {
+ return systemVersionResponse{Version: "fallback", GoVersion: "go-fallback"}
+ }
+
+ findPicoclawBinaryForInfo = func() string { return "picoclaw" }
+ runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
+ return "🦞 picoclaw v1.2.3 (git: deadbeef)\n Build: 2026-03-27T12:34:56Z\n Go: go1.25.8\n", nil
+ }
+
+ h := NewHandler("")
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var got systemVersionResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+
+ if got.Version != "v1.2.3" {
+ t.Fatalf("version = %q, want %q", got.Version, "v1.2.3")
+ }
+ if got.GitCommit != "deadbeef" {
+ t.Fatalf("git_commit = %q, want %q", got.GitCommit, "deadbeef")
+ }
+ if got.BuildTime != "2026-03-27T12:34:56Z" {
+ t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T12:34:56Z")
+ }
+ if got.GoVersion != "go1.25.8" {
+ t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8")
+ }
+}
+
+func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ expected := systemVersionResponse{
+ Version: "v9.9.9",
+ GitCommit: "cafebabe",
+ BuildTime: "2026-03-27T10:43:34+0000",
+ GoVersion: "go1.25.8",
+ }
+ launcherBuildInfoForVersion = func() systemVersionResponse { return expected }
+
+ findPicoclawBinaryForInfo = func() string { return "picoclaw" }
+ runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
+ return "", errors.New("binary unavailable")
+ }
+
+ h := NewHandler("")
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var got systemVersionResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+
+ if got.Version != expected.Version {
+ t.Fatalf("version = %q, want %q", got.Version, expected.Version)
+ }
+ if got.GitCommit != expected.GitCommit {
+ t.Fatalf("git_commit = %q, want %q", got.GitCommit, expected.GitCommit)
+ }
+ if got.BuildTime != expected.BuildTime {
+ t.Fatalf("build_time = %q, want %q", got.BuildTime, expected.BuildTime)
+ }
+ if got.GoVersion != expected.GoVersion {
+ t.Fatalf("go_version = %q, want %q", got.GoVersion, expected.GoVersion)
+ }
+}
+
+func TestParsePicoclawVersionOutput(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ raw := "\u001b[1;31m████\u001b[0m\n🦞 picoclaw 18ec263 (git: 18ec2631)\n Build: 2026-03-27T10:43:34+0000\n Go: go1.25.8\n"
+ got, ok := parsePicoclawVersionOutput(raw)
+ if !ok {
+ t.Fatal("parsePicoclawVersionOutput() should parse valid output")
+ }
+ if got.Version != "18ec263" {
+ t.Fatalf("version = %q, want %q", got.Version, "18ec263")
+ }
+ if got.GitCommit != "18ec2631" {
+ t.Fatalf("git_commit = %q, want %q", got.GitCommit, "18ec2631")
+ }
+ if got.BuildTime != "2026-03-27T10:43:34+0000" {
+ t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T10:43:34+0000")
+ }
+ if got.GoVersion != "go1.25.8" {
+ t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8")
+ }
+}
+
+func TestParsePicoclawVersionOutputIgnoresUsageLine(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ raw := "Usage: picoclaw version [flags]\n"
+ got, ok := parsePicoclawVersionOutput(raw)
+ if ok {
+ t.Fatalf("parsePicoclawVersionOutput() parsed usage line unexpectedly: %#v", got)
+ }
+}
+
+func TestParsePicoclawVersionOutputAcceptsLetterOnlyHashVersion(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ raw := "picoclaw abcdefa (git: abcdefabcdefabcdefabcdefabcdefabcdefabcd)\n"
+ got, ok := parsePicoclawVersionOutput(raw)
+ if !ok {
+ t.Fatal("parsePicoclawVersionOutput() should parse letter-only hash version")
+ }
+ if got.Version != "abcdefa" {
+ t.Fatalf("version = %q, want %q", got.Version, "abcdefa")
+ }
+ if got.GitCommit != "abcdefabcdefabcdefabcdefabcdefabcdefabcd" {
+ t.Fatalf("git_commit = %q, want %q", got.GitCommit, "abcdefabcdefabcdefabcdefabcdefabcdefabcd")
+ }
+}
+
+func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ launcherBuildInfoForVersion = func() systemVersionResponse {
+ return systemVersionResponse{Version: "dev", GoVersion: ""}
+ }
+
+ findPicoclawBinaryForInfo = func() string { return "picoclaw" }
+ runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
+ return "picoclaw v1.0.0\n", nil
+ }
+
+ h := NewHandler("")
+ got := h.resolveSystemVersionInfo(context.Background())
+ if got.GoVersion != runtime.Version() {
+ t.Fatalf("go_version = %q, want runtime version %q", got.GoVersion, runtime.Version())
+ }
+}
+
+func TestResolveSystemVersionInfoCachesWhileGatewayAlive(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ launcherBuildInfoForVersion = func() systemVersionResponse {
+ return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"}
+ }
+ findPicoclawBinaryForInfo = func() string { return "picoclaw" }
+
+ pid := 4321
+ currentGatewayVersionState = func() (int, bool) { return pid, true }
+
+ runCount := 0
+ runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
+ runCount++
+ return fmt.Sprintf("picoclaw v1.2.%d\n", runCount), nil
+ }
+
+ h := NewHandler("")
+ first := h.resolveSystemVersionInfo(context.Background())
+ second := h.resolveSystemVersionInfo(context.Background())
+
+ if first.Version != "v1.2.1" {
+ t.Fatalf("first version = %q, want %q", first.Version, "v1.2.1")
+ }
+ if second.Version != "v1.2.1" {
+ t.Fatalf("second version = %q, want cached %q", second.Version, "v1.2.1")
+ }
+ if runCount != 1 {
+ t.Fatalf("run count = %d, want %d", runCount, 1)
+ }
+}
+
+func TestResolveSystemVersionInfoInvalidatesCacheWhenGatewayStops(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ launcherBuildInfoForVersion = func() systemVersionResponse {
+ return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"}
+ }
+ findPicoclawBinaryForInfo = func() string { return "picoclaw" }
+
+ alive := true
+ pid := 9876
+ currentGatewayVersionState = func() (int, bool) {
+ if !alive {
+ return 0, false
+ }
+ return pid, true
+ }
+
+ runCount := 0
+ runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
+ runCount++
+ return fmt.Sprintf("picoclaw v2.0.%d\n", runCount), nil
+ }
+
+ h := NewHandler("")
+ first := h.resolveSystemVersionInfo(context.Background())
+ second := h.resolveSystemVersionInfo(context.Background())
+
+ if first.Version != "v2.0.1" || second.Version != "v2.0.1" {
+ t.Fatalf("expected cached version v2.0.1, got first=%q second=%q", first.Version, second.Version)
+ }
+ if runCount != 1 {
+ t.Fatalf("run count after cache hit = %d, want %d", runCount, 1)
+ }
+
+ alive = false
+ third := h.resolveSystemVersionInfo(context.Background())
+ if third.Version != "v2.0.2" {
+ t.Fatalf("third version = %q, want refreshed %q", third.Version, "v2.0.2")
+ }
+ if runCount != 2 {
+ t.Fatalf("run count after invalidation = %d, want %d", runCount, 2)
+ }
+}
+
+func TestResolveSystemVersionInfoSkipsCommandWhenContextCanceled(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ launcherBuildInfoForVersion = func() systemVersionResponse {
+ return systemVersionResponse{Version: "v3.0.0", GoVersion: "go-fallback"}
+ }
+ findPicoclawBinaryForInfo = func() string { return "picoclaw" }
+
+ runCount := 0
+ runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
+ runCount++
+ return "picoclaw v9.9.9\n", nil
+ }
+
+ canceledCtx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ h := NewHandler("")
+ got := h.resolveSystemVersionInfo(canceledCtx)
+
+ if runCount != 0 {
+ t.Fatalf("run count = %d, want %d", runCount, 0)
+ }
+ if got.Version != "v3.0.0" {
+ t.Fatalf("version = %q, want fallback %q", got.Version, "v3.0.0")
+ }
+}
+
+func TestResolveGatewayBinaryForVersionInfoPrefersGatewayCommandPath(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ originalFinder := findPicoclawBinaryForInfo
+ t.Cleanup(func() {
+ findPicoclawBinaryForInfo = originalFinder
+ })
+
+ gateway.mu.Lock()
+ originalCmd := gateway.cmd
+ gateway.cmd = &exec.Cmd{Path: "/tmp/picoclaw-from-gateway"}
+ gateway.mu.Unlock()
+ t.Cleanup(func() {
+ gateway.mu.Lock()
+ gateway.cmd = originalCmd
+ gateway.mu.Unlock()
+ })
+
+ got := resolveGatewayBinaryForVersionInfo()
+ if got != "/tmp/picoclaw-from-gateway" {
+ t.Fatalf("exec path = %q, want %q", got, "/tmp/picoclaw-from-gateway")
+ }
+}
diff --git a/web/backend/api/wecom.go b/web/backend/api/wecom.go
index 7dcec9f49..74e5d8e83 100644
--- a/web/backend/api/wecom.go
+++ b/web/backend/api/wecom.go
@@ -216,11 +216,19 @@ func (h *Handler) saveWecomBinding(botID, secret string) error {
return fmt.Errorf("load config: %w", err)
}
- cfg.Channels.WeCom.Enabled = true
- cfg.Channels.WeCom.BotID = botID
- cfg.Channels.WeCom.SetSecret(secret)
- if strings.TrimSpace(cfg.Channels.WeCom.WebSocketURL) == "" {
- cfg.Channels.WeCom.WebSocketURL = wecomDefaultWebSocketURL
+ bc := cfg.Channels.Get(config.ChannelWeCom)
+ if bc == nil {
+ bc = &config.Channel{Type: config.ChannelWeCom}
+ cfg.Channels["wecom"] = bc
+ }
+ bc.Enabled = true
+
+ var wecomCfg config.WeComSettings
+ bc.Decode(&wecomCfg)
+ wecomCfg.BotID = botID
+ wecomCfg.Secret = *config.NewSecureString(secret)
+ if strings.TrimSpace(wecomCfg.WebSocketURL) == "" {
+ wecomCfg.WebSocketURL = wecomDefaultWebSocketURL
}
if err := config.SaveConfig(h.configPath, cfg); err != nil {
return err
diff --git a/web/backend/api/weixin.go b/web/backend/api/weixin.go
index 808b88c41..888789f86 100644
--- a/web/backend/api/weixin.go
+++ b/web/backend/api/weixin.go
@@ -210,11 +210,26 @@ func (h *Handler) saveWeixinBinding(token, accountID string) error {
if err != nil {
return fmt.Errorf("load config: %w", err)
}
- cfg.Channels.Weixin.SetToken(token)
- cfg.Channels.Weixin.Enabled = true
- if accountID != "" {
- cfg.Channels.Weixin.AccountID = accountID
+
+ bc := cfg.Channels.Get(config.ChannelWeixin)
+ if bc == nil {
+ bc = &config.Channel{Type: config.ChannelWeixin}
+ cfg.Channels[config.ChannelWeixin] = bc
}
+ bc.Enabled = true
+
+ var weixinCfg config.WeixinSettings
+ if err := bc.Decode(&weixinCfg); err != nil {
+ logger.ErrorCF("weixin", "failed to decode weixin settings", map[string]any{
+ "error": err.Error(),
+ })
+ return fmt.Errorf("decode weixin settings: %w", err)
+ }
+ weixinCfg.Token = *config.NewSecureString(token)
+ if accountID != "" {
+ weixinCfg.AccountID = accountID
+ }
+
if err := config.SaveConfig(h.configPath, cfg); err != nil {
return err
}
diff --git a/web/backend/api/weixin_test.go b/web/backend/api/weixin_test.go
index ce54eec16..575de7b9c 100644
--- a/web/backend/api/weixin_test.go
+++ b/web/backend/api/weixin_test.go
@@ -44,13 +44,19 @@ func TestSaveWeixinBindingReturnsSuccessWhenRestartFails(t *testing.T) {
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
- if got := savedCfg.Channels.Weixin.Token.String(); got != "bot-token" {
+ bc := savedCfg.Channels["weixin"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ wxCfg := decoded.(*config.WeixinSettings)
+ if got := wxCfg.Token.String(); got != "bot-token" {
t.Fatalf("Weixin.Token() = %q, want %q", got, "bot-token")
}
- if got := savedCfg.Channels.Weixin.AccountID; got != "bot-account" {
+ if got := wxCfg.AccountID; got != "bot-account" {
t.Fatalf("Weixin.AccountID = %q, want %q", got, "bot-account")
}
- if !savedCfg.Channels.Weixin.Enabled {
+ if !bc.Enabled {
t.Fatalf("Weixin.Enabled = false, want true")
}
}
diff --git a/web/backend/app_runtime.go b/web/backend/app_runtime.go
index ab564db2c..a06396526 100644
--- a/web/backend/app_runtime.go
+++ b/web/backend/app_runtime.go
@@ -34,22 +34,30 @@ func shutdownApp() {
apiHandler.Shutdown()
}
- if server != nil {
- // Disable keep-alive to allow graceful shutdown
- server.SetKeepAlivesEnabled(false)
-
- ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
- defer cancel()
- if err := server.Shutdown(ctx); err != nil {
- // Context deadline exceeded is expected if there are active connections
- // This is not necessarily an error, so log it at info level
- if errors.Is(err, context.DeadlineExceeded) {
- logger.Infof("Server shutdown timeout after %v, forcing close", shutdownTimeout)
- } else {
- logger.Errorf("Server shutdown error: %v", err)
+ if len(servers) > 0 {
+ for _, srv := range servers {
+ if srv == nil {
+ continue
+ }
+
+ // Disable keep-alive to allow graceful shutdown
+ srv.SetKeepAlivesEnabled(false)
+
+ ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
+ err := srv.Shutdown(ctx)
+ cancel()
+
+ if err != nil {
+ // Context deadline exceeded is expected if there are active connections
+ // This is not necessarily an error, so log it at info level
+ if errors.Is(err, context.DeadlineExceeded) {
+ logger.Infof("Server shutdown timeout after %v, forcing close", shutdownTimeout)
+ } else {
+ logger.Errorf("Server shutdown error: %v", err)
+ }
+ } else {
+ logger.Infof("Server shutdown completed successfully")
}
- } else {
- logger.Infof("Server shutdown completed successfully")
}
}
}
diff --git a/web/backend/dashboardauth/platform.go b/web/backend/dashboardauth/platform.go
new file mode 100644
index 000000000..25ba5da08
--- /dev/null
+++ b/web/backend/dashboardauth/platform.go
@@ -0,0 +1,7 @@
+package dashboardauth
+
+import "errors"
+
+// ErrUnsupportedPlatform reports that the SQLite-backed password store is not
+// available for the current target platform.
+var ErrUnsupportedPlatform = errors.New("dashboard password store is unavailable on this platform")
diff --git a/web/backend/dashboardauth/sql.go b/web/backend/dashboardauth/sql.go
new file mode 100644
index 000000000..94886072b
--- /dev/null
+++ b/web/backend/dashboardauth/sql.go
@@ -0,0 +1,24 @@
+package dashboardauth
+
+const (
+ // DBFilename is the SQLite database file stored under the PicoClaw home directory.
+ DBFilename = "launcher-auth.db"
+
+ sqliteDriver = "sqlite"
+ // bcryptCost is deliberately high enough to slow brute-force attempts.
+ bcryptCost = 12
+
+ sqlCreateTable = `
+ CREATE TABLE IF NOT EXISTS dashboard_credentials (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ bcrypt_hash TEXT NOT NULL
+ )`
+
+ sqlCountCredentials = `SELECT COUNT(*) FROM dashboard_credentials WHERE id = 1`
+
+ sqlUpsertHash = `
+ INSERT INTO dashboard_credentials (id, bcrypt_hash) VALUES (1, ?)
+ ON CONFLICT(id) DO UPDATE SET bcrypt_hash = excluded.bcrypt_hash`
+
+ sqlSelectHash = `SELECT bcrypt_hash FROM dashboard_credentials WHERE id = 1`
+)
diff --git a/web/backend/dashboardauth/store.go b/web/backend/dashboardauth/store.go
new file mode 100644
index 000000000..870796bba
--- /dev/null
+++ b/web/backend/dashboardauth/store.go
@@ -0,0 +1,96 @@
+//go:build !mipsle && !netbsd && !(freebsd && arm)
+
+// Package dashboardauth provides a bcrypt-backed SQLite store for the
+// launcher dashboard password. The database contains a single row (id=1)
+// with the bcrypt hash; no plaintext is ever persisted.
+package dashboardauth
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "path/filepath"
+
+ "golang.org/x/crypto/bcrypt"
+ _ "modernc.org/sqlite" // register "sqlite" driver
+)
+
+// Store holds a handle to the SQLite database that stores the bcrypt hash.
+type Store struct {
+ db *sql.DB
+ path string // absolute path to the SQLite file
+}
+
+// New opens (or creates) the database inside dir, using the package's
+// canonical filename. This is the preferred constructor for most callers.
+// Any error is wrapped with the resolved path so callers get actionable output.
+func New(dir string) (*Store, error) {
+ path := filepath.Join(dir, DBFilename)
+ s, err := Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("open %q: %w", path, err)
+ }
+ return s, nil
+}
+
+// Open opens (or creates) the SQLite database at path and migrates the schema.
+func Open(path string) (*Store, error) {
+ db, err := sql.Open(sqliteDriver, path)
+ if err != nil {
+ return nil, err
+ }
+ if _, err = db.Exec(sqlCreateTable); err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+ return &Store{db: db, path: path}, nil
+}
+
+// Close releases the database handle.
+func (s *Store) Close() error { return s.db.Close() }
+
+// DBPath returns the absolute path to the SQLite database file.
+func (s *Store) DBPath() string { return s.path }
+
+// IsInitialized reports whether a password hash has been stored.
+func (s *Store) IsInitialized(ctx context.Context) (bool, error) {
+ var n int
+ err := s.db.QueryRowContext(ctx, sqlCountCredentials).Scan(&n)
+ if err != nil {
+ return false, err
+ }
+ return n > 0, nil
+}
+
+// SetPassword hashes plain with bcrypt (cost 12) and stores (or replaces) it.
+// The plaintext is never written to disk.
+func (s *Store) SetPassword(ctx context.Context, plain string) error {
+ if len([]rune(plain)) == 0 {
+ return errors.New("password must not be empty")
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx, sqlUpsertHash, string(hash))
+ return err
+}
+
+// VerifyPassword returns true iff plain matches the stored bcrypt hash.
+// Returns (false, nil) when no password has been set yet.
+func (s *Store) VerifyPassword(ctx context.Context, plain string) (bool, error) {
+ var hash string
+ err := s.db.QueryRowContext(ctx, sqlSelectHash).Scan(&hash)
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
+ if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
+ return false, nil
+ }
+ return err == nil, err
+}
diff --git a/web/backend/dashboardauth/store_unsupported.go b/web/backend/dashboardauth/store_unsupported.go
new file mode 100644
index 000000000..204682020
--- /dev/null
+++ b/web/backend/dashboardauth/store_unsupported.go
@@ -0,0 +1,60 @@
+//go:build mipsle || netbsd || (freebsd && arm)
+
+package dashboardauth
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "runtime"
+)
+
+// Store is unavailable on platforms where modernc sqlite/libc does not build.
+type Store struct {
+ path string
+}
+
+// New reports that the password store is unavailable on this platform.
+func New(dir string) (*Store, error) {
+ path := filepath.Join(dir, DBFilename)
+ s, err := Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("open %q: %w", path, err)
+ }
+ return s, nil
+}
+
+// Open reports that the password store is unavailable on this platform.
+func Open(path string) (*Store, error) {
+ return nil, unsupportedPlatformError()
+}
+
+// Close is a no-op for unsupported platforms.
+func (s *Store) Close() error { return nil }
+
+// DBPath returns the configured path, if any.
+func (s *Store) DBPath() string {
+ if s == nil {
+ return ""
+ }
+ return s.path
+}
+
+// IsInitialized reports that the store is unavailable on this platform.
+func (s *Store) IsInitialized(context.Context) (bool, error) {
+ return false, unsupportedPlatformError()
+}
+
+// SetPassword reports that the store is unavailable on this platform.
+func (s *Store) SetPassword(context.Context, string) error {
+ return unsupportedPlatformError()
+}
+
+// VerifyPassword reports that the store is unavailable on this platform.
+func (s *Store) VerifyPassword(context.Context, string) (bool, error) {
+ return false, unsupportedPlatformError()
+}
+
+func unsupportedPlatformError() error {
+ return fmt.Errorf("%w (%s/%s)", ErrUnsupportedPlatform, runtime.GOOS, runtime.GOARCH)
+}
diff --git a/web/backend/i18n.go b/web/backend/i18n.go
index 106df8506..9cda9e5d5 100644
--- a/web/backend/i18n.go
+++ b/web/backend/i18n.go
@@ -24,8 +24,6 @@ const (
AppTooltip TranslationKey = "AppTooltip"
MenuOpen TranslationKey = "MenuOpen"
MenuOpenTooltip TranslationKey = "MenuOpenTooltip"
- MenuCopyToken TranslationKey = "MenuCopyToken"
- MenuCopyTokenHint TranslationKey = "MenuCopyTokenHint"
MenuAbout TranslationKey = "MenuAbout"
MenuAboutTooltip TranslationKey = "MenuAboutTooltip"
MenuVersion TranslationKey = "MenuVersion"
@@ -49,8 +47,6 @@ var translations = map[Language]map[TranslationKey]string{
AppTooltip: "%s - Web Console",
MenuOpen: "Open Console",
MenuOpenTooltip: "Open PicoClaw console in browser",
- MenuCopyToken: "Copy dashboard token",
- MenuCopyTokenHint: "Copy the current web console access token to the clipboard",
MenuAbout: "About",
MenuAboutTooltip: "About PicoClaw",
MenuVersion: "Version: %s",
@@ -68,8 +64,6 @@ var translations = map[Language]map[TranslationKey]string{
AppTooltip: "%s - Web Console",
MenuOpen: "打开控制台",
MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台",
- MenuCopyToken: "复制控制台口令",
- MenuCopyTokenHint: "将当前 Web 控制台访问口令复制到剪贴板",
MenuAbout: "关于",
MenuAboutTooltip: "关于 PicoClaw",
MenuVersion: "版本: %s",
diff --git a/web/backend/launcherconfig/config.go b/web/backend/launcherconfig/config.go
index b8465ef74..e3595738f 100644
--- a/web/backend/launcherconfig/config.go
+++ b/web/backend/launcherconfig/config.go
@@ -1,8 +1,6 @@
package launcherconfig
import (
- "crypto/rand"
- "encoding/base64"
"encoding/json"
"fmt"
"net"
@@ -16,18 +14,19 @@ const (
FileName = "launcher-config.json"
// DefaultPort is the default port for the web launcher.
DefaultPort = 18800
-
- // dashboardSigningKeyBytes is the HMAC-SHA256 key size (256 bits).
- dashboardSigningKeyBytes = 32
- // dashboardTokenEntropyBytes is CSPRNG length before base64 for the per-run dashboard token (256 bits).
- dashboardTokenEntropyBytes = 32
+ // EnvLauncherHost overrides launcher listen host.
+ EnvLauncherHost = "PICOCLAW_LAUNCHER_HOST"
)
// Config stores launch parameters for the web backend service.
type Config struct {
- Port int `json:"port"`
- Public bool `json:"public"`
- AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
+ Port int `json:"port"`
+ Public bool `json:"public"`
+ AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
+ DashboardPasswordHash string `json:"dashboard_password_hash,omitempty"`
+ // LegacyLauncherToken is read only for one-time migration from the removed
+ // token login flow. Save always clears it so new configs do not persist it.
+ LegacyLauncherToken string `json:"launcher_token,omitempty"`
}
// Default returns default launcher settings.
@@ -48,34 +47,6 @@ func Validate(cfg Config) error {
return nil
}
-// EnsureDashboardSecrets returns signing key bytes and the effective dashboard token for this
-// process. The signing key is freshly random each call; the token comes from the environment
-// variable PICOCLAW_LAUNCHER_TOKEN when set, otherwise a new random token.
-func EnsureDashboardSecrets() (effectiveToken string, signingKey []byte, newRandomDashboardToken bool, err error) {
- signingKey = make([]byte, dashboardSigningKeyBytes)
- if _, err = rand.Read(signingKey); err != nil {
- return "", nil, false, err
- }
-
- effectiveToken = strings.TrimSpace(os.Getenv("PICOCLAW_LAUNCHER_TOKEN"))
- if effectiveToken != "" {
- return effectiveToken, signingKey, false, nil
- }
- tok, genErr := randomDashboardToken()
- if genErr != nil {
- return "", nil, false, genErr
- }
- return tok, signingKey, true, nil
-}
-
-func randomDashboardToken() (string, error) {
- buf := make([]byte, dashboardTokenEntropyBytes)
- if _, err := rand.Read(buf); err != nil {
- return "", err
- }
- return base64.RawURLEncoding.EncodeToString(buf), nil
-}
-
// NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs.
func NormalizeCIDRs(cidrs []string) []string {
if len(cidrs) == 0 {
@@ -124,6 +95,8 @@ func Load(path string, fallback Config) (Config, error) {
return Config{}, err
}
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
+ cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash)
+ cfg.LegacyLauncherToken = strings.TrimSpace(cfg.LegacyLauncherToken)
if err := Validate(cfg); err != nil {
return Config{}, err
}
@@ -133,6 +106,8 @@ func Load(path string, fallback Config) (Config, error) {
// Save writes launcher settings to disk.
func Save(path string, cfg Config) error {
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
+ cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash)
+ cfg.LegacyLauncherToken = ""
if err := Validate(cfg); err != nil {
return err
}
diff --git a/web/backend/launcherconfig/config_test.go b/web/backend/launcherconfig/config_test.go
index 4e8a54e41..bb13ea115 100644
--- a/web/backend/launcherconfig/config_test.go
+++ b/web/backend/launcherconfig/config_test.go
@@ -1,11 +1,10 @@
package launcherconfig
import (
+ "context"
"os"
"path/filepath"
"testing"
-
- "github.com/sipeed/picoclaw/web/backend/middleware"
)
func TestLoadReturnsFallbackWhenMissing(t *testing.T) {
@@ -25,9 +24,11 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "launcher-config.json")
want := Config{
- Port: 18080,
- Public: true,
- AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"},
+ Port: 18080,
+ Public: true,
+ AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"},
+ DashboardPasswordHash: "$2a$12$saved-dashboard-password-hash",
+ LegacyLauncherToken: "legacy-token-should-not-persist",
}
if err := Save(path, want); err != nil {
@@ -40,6 +41,12 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
if got.Port != want.Port || got.Public != want.Public {
t.Fatalf("Load() = %+v, want %+v", got, want)
}
+ if got.DashboardPasswordHash != want.DashboardPasswordHash {
+ t.Fatalf("dashboard_password_hash = %q, want %q", got.DashboardPasswordHash, want.DashboardPasswordHash)
+ }
+ if got.LegacyLauncherToken != "" {
+ t.Fatalf("legacy launcher_token = %q, want empty after Save", got.LegacyLauncherToken)
+ }
if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) {
t.Fatalf("allowed_cidrs len = %d, want %d", len(got.AllowedCIDRs), len(want.AllowedCIDRs))
}
@@ -58,6 +65,21 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
}
}
+func TestLoadReadsLegacyLauncherTokenForMigration(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "launcher-config.json")
+ if err := os.WriteFile(path, []byte(`{"port":18800,"launcher_token":"legacy-token"}`), 0o600); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ got, err := Load(path, Default())
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ if got.LegacyLauncherToken != "legacy-token" {
+ t.Fatalf("legacy launcher_token = %q, want legacy-token", got.LegacyLauncherToken)
+ }
+}
+
func TestValidateRejectsInvalidPort(t *testing.T) {
if err := Validate(Config{Port: 0, Public: false}); err == nil {
t.Fatal("Validate() expected error for port 0")
@@ -77,51 +99,6 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) {
}
}
-func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) {
- t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "")
-
- tok, key, newTok, err := EnsureDashboardSecrets()
- if err != nil {
- t.Fatalf("EnsureDashboardSecrets() error = %v", err)
- }
- if !newTok || tok == "" || len(key) != dashboardSigningKeyBytes {
- t.Fatalf("unexpected first call: newTok=%v tok=%q keyLen=%d", newTok, tok, len(key))
- }
- mac := middleware.SessionCookieValue(key, tok)
- if mac == "" {
- t.Fatal("empty session mac")
- }
-
- tok2, key2, newTok2, err := EnsureDashboardSecrets()
- if err != nil {
- t.Fatalf("EnsureDashboardSecrets() second error = %v", err)
- }
- if !newTok2 {
- t.Fatal("second call without env should generate another random token")
- }
- if tok2 == tok {
- t.Fatal("expected a new random dashboard token")
- }
- if string(key2) == string(key) {
- t.Fatal("expected a new signing key")
- }
-}
-
-func TestEnsureDashboardSecrets_EnvOverridesGenerated(t *testing.T) {
- t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "env-only-token-override")
-
- tok, _, newTok, err := EnsureDashboardSecrets()
- if err != nil {
- t.Fatalf("EnsureDashboardSecrets() error = %v", err)
- }
- if tok != "env-only-token-override" {
- t.Fatalf("token = %q, want env value", tok)
- }
- if newTok {
- t.Fatal("newRandomDashboardToken should be false when env is set")
- }
-}
-
func TestNormalizeCIDRs(t *testing.T) {
got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"})
want := []string{"192.168.1.0/24", "10.0.0.0/8"}
@@ -134,3 +111,42 @@ func TestNormalizeCIDRs(t *testing.T) {
}
}
}
+
+func TestPasswordStoreSetAndVerify(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "launcher-config.json")
+ store := NewPasswordStore(path, Default())
+ ctx := context.Background()
+
+ initialized, err := store.IsInitialized(ctx)
+ if err != nil {
+ t.Fatalf("IsInitialized() error = %v", err)
+ }
+ if initialized {
+ t.Fatal("IsInitialized() = true, want false before SetPassword")
+ }
+
+ if err = store.SetPassword(ctx, "dashboard-password"); err != nil {
+ t.Fatalf("SetPassword() error = %v", err)
+ }
+ initialized, err = store.IsInitialized(ctx)
+ if err != nil {
+ t.Fatalf("IsInitialized() after SetPassword error = %v", err)
+ }
+ if !initialized {
+ t.Fatal("IsInitialized() = false, want true after SetPassword")
+ }
+ ok, err := store.VerifyPassword(ctx, "dashboard-password")
+ if err != nil {
+ t.Fatalf("VerifyPassword() error = %v", err)
+ }
+ if !ok {
+ t.Fatal("VerifyPassword(correct) = false, want true")
+ }
+ ok, err = store.VerifyPassword(ctx, "wrong-password")
+ if err != nil {
+ t.Fatalf("VerifyPassword(wrong) error = %v", err)
+ }
+ if ok {
+ t.Fatal("VerifyPassword(wrong) = true, want false")
+ }
+}
diff --git a/web/backend/launcherconfig/migration.go b/web/backend/launcherconfig/migration.go
new file mode 100644
index 000000000..66caa73ae
--- /dev/null
+++ b/web/backend/launcherconfig/migration.go
@@ -0,0 +1,62 @@
+package launcherconfig
+
+import (
+ "context"
+ "strings"
+)
+
+var (
+ loadConfigForMigration = Load
+ saveConfigForMigration = Save
+)
+
+type dashboardPasswordStore interface {
+ IsInitialized(ctx context.Context) (bool, error)
+ SetPassword(ctx context.Context, plain string) error
+}
+
+// LegacyLauncherTokenMigrationResult reports the outcome of converting a
+// removed launcher_token value into the current password-based auth flow.
+type LegacyLauncherTokenMigrationResult struct {
+ Migrated bool
+ // CleanupErr is non-nil when password migration succeeded (or was already in
+ // place) but removing launcher_token from launcher-config.json failed.
+ CleanupErr error
+}
+
+// MigrateLegacyLauncherToken converts the removed launcher_token setting into
+// the current password-login store, then removes launcher_token from config.
+func MigrateLegacyLauncherToken(
+ ctx context.Context,
+ store dashboardPasswordStore,
+ launcherPath string,
+ fallback Config,
+) (LegacyLauncherTokenMigrationResult, error) {
+ legacyToken := strings.TrimSpace(fallback.LegacyLauncherToken)
+ if legacyToken == "" || store == nil {
+ return LegacyLauncherTokenMigrationResult{}, nil
+ }
+
+ result := LegacyLauncherTokenMigrationResult{}
+ initialized, err := store.IsInitialized(ctx)
+ if err != nil {
+ return result, err
+ }
+ if !initialized {
+ if err = store.SetPassword(ctx, legacyToken); err != nil {
+ return result, err
+ }
+ result.Migrated = true
+ }
+ result.CleanupErr = cleanupLegacyLauncherTokenConfig(launcherPath, fallback)
+ return result, nil
+}
+
+func cleanupLegacyLauncherTokenConfig(launcherPath string, fallback Config) error {
+ cfg, err := loadConfigForMigration(launcherPath, fallback)
+ if err != nil {
+ return err
+ }
+ cfg.LegacyLauncherToken = ""
+ return saveConfigForMigration(launcherPath, cfg)
+}
diff --git a/web/backend/launcherconfig/migration_test.go b/web/backend/launcherconfig/migration_test.go
new file mode 100644
index 000000000..c5c5fa2c9
--- /dev/null
+++ b/web/backend/launcherconfig/migration_test.go
@@ -0,0 +1,135 @@
+package launcherconfig
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+type stubMigrationPasswordStore struct {
+ initialized bool
+ password string
+}
+
+func (s *stubMigrationPasswordStore) IsInitialized(context.Context) (bool, error) {
+ return s.initialized, nil
+}
+
+func (s *stubMigrationPasswordStore) SetPassword(_ context.Context, plain string) error {
+ s.password = plain
+ s.initialized = true
+ return nil
+}
+
+func TestMigrateLegacyLauncherToken(t *testing.T) {
+ dir := t.TempDir()
+ launcherPath := filepath.Join(dir, FileName)
+ cfg := Config{
+ Port: DefaultPort,
+ LegacyLauncherToken: "legacy-password",
+ }
+ if err := os.WriteFile(
+ launcherPath,
+ []byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"),
+ 0o600,
+ ); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ store := NewPasswordStore(launcherPath, Default())
+ result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg)
+ if err != nil {
+ t.Fatalf("MigrateLegacyLauncherToken() error = %v", err)
+ }
+ if !result.Migrated {
+ t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true")
+ }
+ if result.CleanupErr != nil {
+ t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr)
+ }
+
+ loaded, err := Load(launcherPath, Default())
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ if loaded.LegacyLauncherToken != "" {
+ t.Fatalf("legacy launcher token = %q, want empty", loaded.LegacyLauncherToken)
+ }
+ if loaded.DashboardPasswordHash == "" {
+ t.Fatal("dashboard password hash should be set after migration")
+ }
+ ok, err := store.VerifyPassword(context.Background(), "legacy-password")
+ if err != nil {
+ t.Fatalf("VerifyPassword() error = %v", err)
+ }
+ if !ok {
+ t.Fatal("VerifyPassword() = false, want true")
+ }
+}
+
+func TestMigrateLegacyLauncherTokenCleanupFailureIsNonFatal(t *testing.T) {
+ dir := t.TempDir()
+ launcherPath := filepath.Join(dir, FileName)
+ cfg := Config{
+ Port: DefaultPort,
+ LegacyLauncherToken: "legacy-password",
+ }
+ if err := os.WriteFile(
+ launcherPath,
+ []byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"),
+ 0o600,
+ ); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ store := &stubMigrationPasswordStore{}
+ origSave := saveConfigForMigration
+ saveConfigForMigration = func(string, Config) error {
+ return errors.New("write launcher config")
+ }
+ t.Cleanup(func() {
+ saveConfigForMigration = origSave
+ })
+
+ result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg)
+ if err != nil {
+ t.Fatalf("MigrateLegacyLauncherToken() error = %v, want nil", err)
+ }
+ if !result.Migrated {
+ t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true")
+ }
+ if result.CleanupErr == nil {
+ t.Fatal("MigrateLegacyLauncherToken().CleanupErr = nil, want non-nil")
+ }
+ if store.password != "legacy-password" {
+ t.Fatalf("password = %q, want legacy-password", store.password)
+ }
+
+ loaded, err := Load(launcherPath, Default())
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ if loaded.LegacyLauncherToken != "legacy-password" {
+ t.Fatalf(
+ "legacy launcher token = %q, want legacy-password after cleanup failure",
+ loaded.LegacyLauncherToken,
+ )
+ }
+}
+
+func TestMigrateLegacyLauncherTokenNoopWithoutToken(t *testing.T) {
+ launcherPath := filepath.Join(t.TempDir(), FileName)
+ store := NewPasswordStore(launcherPath, Default())
+ result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, Default())
+ if err != nil {
+ t.Fatalf("MigrateLegacyLauncherToken() error = %v", err)
+ }
+ if result.Migrated {
+ t.Fatal("MigrateLegacyLauncherToken().Migrated = true, want false")
+ }
+ if result.CleanupErr != nil {
+ t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr)
+ }
+}
diff --git a/web/backend/launcherconfig/password_store.go b/web/backend/launcherconfig/password_store.go
new file mode 100644
index 000000000..3813384bb
--- /dev/null
+++ b/web/backend/launcherconfig/password_store.go
@@ -0,0 +1,92 @@
+package launcherconfig
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "sync"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+const passwordBcryptCost = 12
+
+// PasswordStore keeps the dashboard bcrypt hash in launcher-config.json.
+// It is used on platforms where the SQLite-backed dashboard auth store is not
+// available.
+type PasswordStore struct {
+ path string
+ fallback Config
+ mu sync.Mutex
+}
+
+// NewPasswordStore returns a config-backed password store.
+func NewPasswordStore(path string, fallback Config) *PasswordStore {
+ return &PasswordStore{
+ path: path,
+ fallback: fallback,
+ }
+}
+
+// IsInitialized reports whether a dashboard password hash exists in config.
+func (s *PasswordStore) IsInitialized(ctx context.Context) (bool, error) {
+ if err := ctx.Err(); err != nil {
+ return false, err
+ }
+ cfg, err := s.load()
+ if err != nil {
+ return false, err
+ }
+ return strings.TrimSpace(cfg.DashboardPasswordHash) != "", nil
+}
+
+// SetPassword hashes plain with bcrypt and writes it to launcher-config.json.
+func (s *PasswordStore) SetPassword(ctx context.Context, plain string) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if len([]rune(plain)) == 0 {
+ return errors.New("password must not be empty")
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(plain), passwordBcryptCost)
+ if err != nil {
+ return err
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ cfg, err := Load(s.path, s.fallback)
+ if err != nil {
+ return err
+ }
+ cfg.DashboardPasswordHash = string(hash)
+ cfg.LegacyLauncherToken = ""
+ return Save(s.path, cfg)
+}
+
+// VerifyPassword returns true iff plain matches the stored bcrypt hash.
+func (s *PasswordStore) VerifyPassword(ctx context.Context, plain string) (bool, error) {
+ if err := ctx.Err(); err != nil {
+ return false, err
+ }
+ cfg, err := s.load()
+ if err != nil {
+ return false, err
+ }
+ hash := strings.TrimSpace(cfg.DashboardPasswordHash)
+ if hash == "" {
+ return false, nil
+ }
+ err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
+ if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
+ return false, nil
+ }
+ return err == nil, err
+}
+
+func (s *PasswordStore) load() (Config, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return Load(s.path, s.fallback)
+}
diff --git a/web/backend/main.go b/web/backend/main.go
index c58e97361..fa2448d5c 100644
--- a/web/backend/main.go
+++ b/web/backend/main.go
@@ -12,21 +12,25 @@
package main
import (
+ "context"
"errors"
"flag"
"fmt"
+ "net"
"net/http"
- "net/url"
"os"
"os/signal"
"path/filepath"
"strconv"
+ "strings"
"syscall"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/netbind"
"github.com/sipeed/picoclaw/web/backend/api"
+ "github.com/sipeed/picoclaw/web/backend/dashboardauth"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
"github.com/sipeed/picoclaw/web/backend/middleware"
"github.com/sipeed/picoclaw/web/backend/utils"
@@ -43,40 +47,332 @@ const (
var (
appVersion = config.Version
- server *http.Server
+ servers []*http.Server
serverAddr string
// browserLaunchURL is opened by openBrowser() (auto-open + tray "open console").
- // Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use.
browserLaunchURL string
apiHandler *api.Handler
- // launcherDashboardTokenForClipboard is read by the system tray "copy token" action (GUI mode).
- launcherDashboardTokenForClipboard string
noBrowser *bool
)
+func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool {
+ return !enableConsole || debug
+}
+
+func shouldEnableLocalAutoLogin(noBrowser bool, probeHost string) bool {
+ return !noBrowser && isLoopbackLaunchHost(probeHost)
+}
+
+func isLoopbackLaunchHost(host string) bool {
+ host = strings.TrimSpace(host)
+ if strings.EqualFold(host, "localhost") {
+ return true
+ }
+ host = strings.Trim(host, "[]")
+ if i := strings.LastIndex(host, "%"); i >= 0 {
+ host = host[:i]
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
+}
+
+func launcherBrowserLaunchSuffix(
+ needsSetup bool,
+ localAutoLogin *middleware.LauncherDashboardLocalAutoLogin,
+) string {
+ if needsSetup {
+ return middleware.LauncherDashboardSetupPath
+ }
+ if localAutoLogin != nil {
+ return localAutoLogin.URLPath()
+ }
+ return ""
+}
+
+func resolveLauncherHostInput(flagHost string, explicitFlag bool, envHost string) (string, bool, error) {
+ if explicitFlag {
+ normalized, err := netbind.NormalizeHostInput(flagHost)
+ if err != nil {
+ return "", false, err
+ }
+ return normalized, true, nil
+ }
+
+ envHost = strings.TrimSpace(envHost)
+ if envHost == "" {
+ return "", false, nil
+ }
+
+ normalized, err := netbind.NormalizeHostInput(envHost)
+ if err != nil {
+ return "", false, err
+ }
+ return normalized, true, nil
+}
+
+func openLauncherListeners(hostInput string, public bool, port string) (netbind.OpenResult, error) {
+ defaultMode := netbind.DefaultLoopback
+ if strings.TrimSpace(hostInput) == "" && public {
+ defaultMode = netbind.DefaultAny
+ }
+
+ plan, err := netbind.BuildPlan(hostInput, defaultMode)
+ if err != nil {
+ return netbind.OpenResult{}, err
+ }
+ return netbind.OpenPlan(plan, port)
+}
+
+func appendUniqueHost(hosts []string, seen map[string]struct{}, host string) []string {
+ host = strings.TrimSpace(host)
+ if host == "" {
+ return hosts
+ }
+ key := strings.ToLower(host)
+ if _, ok := seen[key]; ok {
+ return hosts
+ }
+ seen[key] = struct{}{}
+ return append(hosts, host)
+}
+
+func hasWildcardBindHosts(bindHosts []string) bool {
+ for _, bindHost := range bindHosts {
+ if netbind.IsUnspecifiedHost(bindHost) {
+ return true
+ }
+ }
+ return false
+}
+
+func wildcardBindHostFamilies(bindHosts []string) (hasIPv4, hasIPv6 bool) {
+ for _, bindHost := range bindHosts {
+ host := strings.TrimSpace(bindHost)
+ if host == "" {
+ continue
+ }
+
+ if !netbind.IsUnspecifiedHost(host) {
+ continue
+ }
+
+ ip := net.ParseIP(strings.Trim(host, "[]"))
+ if ip == nil {
+ continue
+ }
+ if ip.To4() != nil {
+ hasIPv4 = true
+ continue
+ }
+ hasIPv6 = true
+ }
+
+ return hasIPv4, hasIPv6
+}
+
+func wildcardAdvertiseIP(bindHosts []string, ipv4, ipv6 string) string {
+ hasIPv4Wildcard, hasIPv6Wildcard := wildcardBindHostFamilies(bindHosts)
+ v4 := strings.TrimSpace(ipv4)
+ v6 := strings.TrimSpace(ipv6)
+
+ switch {
+ case hasIPv4Wildcard && hasIPv6Wildcard:
+ if v6 != "" {
+ return v6
+ }
+ return v4
+ case hasIPv6Wildcard:
+ return v6
+ case hasIPv4Wildcard:
+ return v4
+ default:
+ return ""
+ }
+}
+
+func advertiseIPForWildcardBindHosts(bindHosts []string) string {
+ return wildcardAdvertiseIP(bindHosts, utils.GetLocalIPv4(), utils.GetLocalIPv6())
+}
+
+func appendLauncherConsoleHostList(hosts []string, seen map[string]struct{}, values []string) []string {
+ for _, value := range values {
+ hosts = appendUniqueHost(hosts, seen, value)
+ }
+ return hosts
+}
+
+func shouldShowLocalhostConsoleEntry(hostInput string) bool {
+ normalizedHostInput := strings.TrimSpace(hostInput)
+ if normalizedHostInput == "" {
+ return true
+ }
+
+ for token := range strings.SplitSeq(normalizedHostInput, ",") {
+ token = strings.TrimSpace(token)
+ if token == "" {
+ continue
+ }
+ if token == "*" || strings.EqualFold(token, "localhost") {
+ return true
+ }
+
+ ip := net.ParseIP(strings.Trim(token, "[]"))
+ if ip == nil {
+ continue
+ }
+ if ip4 := ip.To4(); ip4 != nil {
+ if ip4.String() == "127.0.0.1" || ip4.String() == "0.0.0.0" {
+ return true
+ }
+ continue
+ }
+ if ip.String() == "::1" || ip.String() == "::" {
+ return true
+ }
+ }
+
+ return false
+}
+
+func isConsoleDisplayGlobalIPv6(ip net.IP) bool {
+ if ip == nil || ip.IsLoopback() || ip.To4() != nil {
+ return false
+ }
+ ip = ip.To16()
+ if ip == nil {
+ return false
+ }
+ return ip[0]&0xe0 == 0x20
+}
+
+func launcherConsoleHostsWithLocalAddrs(
+ hostInput string,
+ public bool,
+ ipv4s []string,
+ globalIPv6s []string,
+) []string {
+ hosts := make([]string, 0, 8)
+ seen := make(map[string]struct{}, 8)
+
+ if shouldShowLocalhostConsoleEntry(hostInput) {
+ hosts = appendUniqueHost(hosts, seen, "localhost")
+ }
+
+ normalizedHostInput := strings.TrimSpace(hostInput)
+ if normalizedHostInput == "" {
+ if public {
+ hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s)
+ hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s)
+ }
+ return hosts
+ }
+
+ hasStar := false
+ hasIPv4Any := false
+ hasIPv6Any := false
+ for _, token := range strings.Split(normalizedHostInput, ",") {
+ switch strings.TrimSpace(token) {
+ case "*":
+ hasStar = true
+ case "0.0.0.0":
+ hasIPv4Any = true
+ case "::":
+ hasIPv6Any = true
+ }
+ }
+
+ if hasStar {
+ hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s)
+ hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s)
+ return hosts
+ }
+
+ for _, token := range strings.Split(normalizedHostInput, ",") {
+ token = strings.TrimSpace(token)
+ if token == "" || strings.EqualFold(token, "localhost") || netbind.IsLoopbackHost(token) {
+ continue
+ }
+
+ ip := net.ParseIP(strings.Trim(token, "[]"))
+ switch {
+ case token == "::":
+ hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s)
+ case token == "0.0.0.0":
+ hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s)
+ case ip != nil && ip.To4() != nil:
+ if hasIPv4Any {
+ continue
+ }
+ hosts = appendUniqueHost(hosts, seen, ip.String())
+ case ip != nil:
+ if hasIPv6Any {
+ continue
+ }
+ if isConsoleDisplayGlobalIPv6(ip) {
+ hosts = appendUniqueHost(hosts, seen, ip.String())
+ }
+ default:
+ hosts = appendUniqueHost(hosts, seen, token)
+ }
+ }
+
+ return hosts
+}
+
+func launcherConsoleHosts(hostInput string, public bool) []string {
+ return launcherConsoleHostsWithLocalAddrs(
+ hostInput,
+ public,
+ utils.GetLocalIPv4s(),
+ utils.GetGlobalIPv6s(),
+ )
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ value = strings.TrimSpace(value)
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
func main() {
port := flag.String("port", "18800", "Port to listen on")
- public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
+ host := flag.String("host", "", "Host to listen on (overrides -public when set)")
+ public := flag.Bool("public", false, "Listen on all interfaces (dual-stack) instead of localhost only")
noBrowser = flag.Bool("no-browser", false, "Do not auto-open browser on startup")
lang := flag.String("lang", "", "Language: en (English) or zh (Chinese). Default: auto-detect from system locale")
console := flag.Bool("console", false, "Console mode, no GUI")
+ var debug bool
+ flag.BoolVar(&debug, "d", false, "Enable debug logging")
+ flag.BoolVar(&debug, "debug", false, "Enable debug logging")
+
flag.Usage = func() {
- fmt.Fprintf(os.Stderr, "%s Launcher - A web-based configuration editor\n\n", appName)
+ fmt.Fprintf(os.Stderr, "%s Launcher - Web console and gateway manager\n\n", appName)
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Arguments:\n")
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
fmt.Fprintf(os.Stderr, "Options:\n")
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nExamples:\n")
- fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0])
- fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0])
+ fmt.Fprintf(os.Stderr, " %s\n", os.Args[0])
+ fmt.Fprintf(os.Stderr, " Use default config path in GUI mode\n")
+ fmt.Fprintf(os.Stderr, " %s ./config.json\n", os.Args[0])
+ fmt.Fprintf(os.Stderr, " Specify a config file\n")
fmt.Fprintf(
os.Stderr,
- " %s -public ./config.json Allow access from other devices on the network\n",
+ " %s -public ./config.json\n",
os.Args[0],
)
+ fmt.Fprintf(os.Stderr, " Allow access from other devices on the local network\n")
+ fmt.Fprintf(os.Stderr, " %s -host :: ./config.json\n", os.Args[0])
+ fmt.Fprintf(os.Stderr, " Bind launcher host explicitly with exact host semantics\n")
+ fmt.Fprintf(os.Stderr, " %s -console -d ./config.json\n", os.Args[0])
+ fmt.Fprintf(os.Stderr, " Run in the terminal with debug logs enabled\n")
}
flag.Parse()
@@ -90,12 +386,13 @@ func main() {
}
defer panicFunc()
- // By default, detect terminal to decide console log behavior
- // If -console-logs flag is explicitly set, it overrides the detection
enableConsole := *console
- if !enableConsole {
- // Disable console logging by setting level to Fatal (no output)
- logger.SetConsoleLevel(logger.FATAL)
+ fileLoggingEnabled := shouldEnableLauncherFileLogging(enableConsole, debug)
+ if fileLoggingEnabled {
+ // GUI mode writes launcher logs to file. Debug mode keeps file logging enabled in console mode too.
+ if !debug {
+ logger.DisableConsole()
+ }
f := filepath.Join(picoHome, logPath, logFile)
if err = logger.EnableFileLogging(f); err != nil {
@@ -103,9 +400,9 @@ func main() {
}
defer logger.DisableFileLogging()
}
-
- logger.InfoC("web", fmt.Sprintf("%s launcher starting (version %s)...", appName, appVersion))
- logger.InfoC("web", fmt.Sprintf("%s Home: %s", appName, picoHome))
+ if debug {
+ logger.SetLevel(logger.DEBUG)
+ }
// Set language from command line or auto-detect
if *lang != "" {
@@ -126,13 +423,36 @@ func main() {
if err != nil {
logger.Errorf("Warning: Failed to initialize %s config automatically: %v", appName, err)
}
+ if !debug {
+ logger.SetLevelFromString(config.ResolveGatewayLogLevel(absPath))
+ }
+
+ logger.InfoC("web", fmt.Sprintf("%s launcher starting (version %s)...", appName, appVersion))
+ logger.InfoC("web", fmt.Sprintf("%s Home: %s", appName, picoHome))
+ if debug {
+ logger.InfoC("web", "Debug mode enabled")
+ logger.DebugC(
+ "web",
+ fmt.Sprintf(
+ "Launcher flags: console=%t host=%q public=%t no_browser=%t config=%s",
+ enableConsole,
+ *host,
+ *public,
+ *noBrowser,
+ absPath,
+ ),
+ )
+ }
var explicitPort bool
var explicitPublic bool
+ var explicitHost bool
flag.Visit(func(f *flag.Flag) {
switch f.Name {
case "port":
explicitPort = true
+ case "host":
+ explicitHost = true
case "public":
explicitPublic = true
}
@@ -153,6 +473,23 @@ func main() {
if !explicitPublic {
effectivePublic = launcherCfg.Public
}
+ envHost := strings.TrimSpace(os.Getenv(launcherconfig.EnvLauncherHost))
+
+ hostInput, hostOverrideActive, err := resolveLauncherHostInput(*host, explicitHost, envHost)
+ if err != nil {
+ logger.Fatalf("Invalid host %q: %v", firstNonEmpty(strings.TrimSpace(*host), envHost), err)
+ }
+ if hostOverrideActive {
+ effectivePublic = false
+ }
+
+ if !explicitHost && hostOverrideActive {
+ logger.InfoC("web", "Using launcher host from environment PICOCLAW_LAUNCHER_HOST")
+ }
+
+ if hostOverrideActive && explicitPublic {
+ logger.InfoC("web", "Ignoring -public because launcher host was explicitly set")
+ }
portNum, err := strconv.Atoi(effectivePort)
if err != nil || portNum < 1 || portNum > 65535 {
@@ -162,45 +499,93 @@ func main() {
logger.Fatalf("Invalid port %q: %v", effectivePort, err)
}
- dashboardToken, dashboardSigningKey, newDashTok, dashErr := launcherconfig.EnsureDashboardSecrets()
+ openResult, err := openLauncherListeners(hostInput, effectivePublic, effectivePort)
+ if err != nil {
+ logger.Fatalf("Failed to open launcher listener(s): %v", err)
+ }
+ listeners := openResult.Listeners
+
+ dashboardSessionCookie, dashErr := middleware.NewLauncherDashboardSessionCookie()
if dashErr != nil {
logger.Fatalf("Dashboard auth setup failed: %v", dashErr)
}
- dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken)
- launcherDashboardTokenForClipboard = dashboardToken
- // Determine listen address
- var addr string
- if effectivePublic {
- addr = "0.0.0.0:" + effectivePort
+ // Open the bcrypt password store (creates the DB file on first run).
+ authStore, authStoreErr := dashboardauth.New(picoHome)
+ var passwordStore api.PasswordStore
+ if authStoreErr == nil {
+ passwordStore = authStore
+ defer authStore.Close()
+ } else if errors.Is(authStoreErr, dashboardauth.ErrUnsupportedPlatform) {
+ logger.InfoC(
+ "web",
+ fmt.Sprintf(
+ "Dashboard SQLite password store unavailable on this platform; using launcher-config password storage: %v",
+ authStoreErr,
+ ),
+ )
+ passwordStore = launcherconfig.NewPasswordStore(launcherPath, launcherCfg)
+ authStoreErr = nil
} else {
- addr = "127.0.0.1:" + effectivePort
+ logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
+ }
+
+ migrationResult, migrationErr := launcherconfig.MigrateLegacyLauncherToken(
+ context.Background(),
+ passwordStore,
+ launcherPath,
+ launcherCfg,
+ )
+ if migrationErr != nil {
+ logger.Fatalf("Failed to migrate legacy launcher token to password login: %v", migrationErr)
+ }
+ if migrationResult.Migrated {
+ logger.InfoC("web", "Migrated legacy launcher token to dashboard password login")
+ }
+ if migrationResult.CleanupErr != nil {
+ logger.WarnC(
+ "web",
+ fmt.Sprintf(
+ "Legacy launcher token password migration succeeded, but failed to remove launcher_token from %s: %v",
+ launcherPath,
+ migrationResult.CleanupErr,
+ ),
+ )
+ }
+
+ var localAutoLogin *middleware.LauncherDashboardLocalAutoLogin
+ needsInitialSetup := false
+ if passwordStore != nil {
+ initialized, initErr := passwordStore.IsInitialized(context.Background())
+ if initErr != nil {
+ logger.ErrorC("web", fmt.Sprintf("Warning: could not check dashboard password state: %v", initErr))
+ } else if !initialized {
+ needsInitialSetup = true
+ } else if shouldEnableLocalAutoLogin(*noBrowser, openResult.ProbeHost) {
+ localAutoLogin, err = middleware.NewLauncherDashboardLocalAutoLogin(5 * time.Minute)
+ if err != nil {
+ logger.Fatalf("Failed to create local auto-login grant: %v", err)
+ }
+ }
}
// Initialize Server components
mux := http.NewServeMux()
- tokenLogFileAbs := ""
- if !enableConsole {
- tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile)
- }
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
- DashboardToken: dashboardToken,
- SessionCookie: dashboardSessionCookie,
- TokenHelp: api.LauncherAuthTokenHelp{
- EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
- LogFileAbs: tokenLogFileAbs,
- TrayCopyMenu: trayOffersDashboardTokenCopy(),
- ConsoleStdout: enableConsole,
- },
+ SessionCookie: dashboardSessionCookie,
+ PasswordStore: passwordStore,
+ StoreError: authStoreErr,
})
// API Routes (e.g. /api/status)
apiHandler = api.NewHandler(absPath)
- if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
+ apiHandler.SetDebug(debug)
+ if _, err = apiHandler.EnsurePicoChannel(); err != nil {
logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err))
}
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
+ apiHandler.SetServerBindHost(hostInput, hostOverrideActive)
apiHandler.RegisterRoutes(mux)
// Frontend Embedded Assets
@@ -213,7 +598,7 @@ func main() {
dashAuth := middleware.LauncherDashboardAuth(middleware.LauncherDashboardAuthConfig{
ExpectedCookie: dashboardSessionCookie,
- Token: dashboardToken,
+ LocalAutoLogin: localAutoLogin,
}, accessControlledMux)
// Apply middleware stack
@@ -225,49 +610,41 @@ func main() {
),
)
- // Print startup banner and token (console mode only).
- if enableConsole {
+ // Print startup banner (console mode only).
+ if enableConsole || debug {
+ consoleHosts := launcherConsoleHosts(hostInput, effectivePublic)
+
fmt.Print(utils.Banner)
fmt.Println()
- fmt.Println(" Open the following URL in your browser:")
- fmt.Println()
- fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
- if effectivePublic {
- if ip := utils.GetLocalIP(); ip != "" {
- fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort)
+ if needsInitialSetup {
+ if *noBrowser {
+ fmt.Println(" First-time setup: open /launcher-setup to create the dashboard password.")
+ } else {
+ fmt.Println(" Launcher will open /launcher-setup automatically.")
}
+ fmt.Println()
+ }
+ fmt.Println(" Dashboard address:")
+ fmt.Println()
+ for _, host := range consoleHosts {
+ fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort))
}
fmt.Println()
- if newDashTok {
- fmt.Printf(" Dashboard token (this run): %s\n", dashboardToken)
- } else if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" {
- fmt.Printf(" Dashboard token: %s (from PICOCLAW_LAUNCHER_TOKEN)\n", dashboardToken)
- }
- fmt.Println()
- }
-
- if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" {
- logger.InfoC("web", "Dashboard token: environment PICOCLAW_LAUNCHER_TOKEN")
- }
- if !enableConsole && newDashTok {
- logger.InfoC("web", "Dashboard token (this run): "+dashboardToken)
}
// Log startup info to file
- logger.InfoC("web", fmt.Sprintf("Server will listen on http://localhost:%s", effectivePort))
- if effectivePublic {
- if ip := utils.GetLocalIP(); ip != "" {
- logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s:%s", ip, effectivePort))
+ for _, ln := range listeners {
+ logger.InfoC("web", fmt.Sprintf("Server will listen on http://%s", ln.Addr().String()))
+ }
+ if hasWildcardBindHosts(openResult.BindHosts) {
+ if ip := advertiseIPForWildcardBindHosts(openResult.BindHosts); ip != "" {
+ logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s", net.JoinHostPort(ip, effectivePort)))
}
}
// Share the local URL with the launcher runtime.
- serverAddr = fmt.Sprintf("http://localhost:%s", effectivePort)
- if dashboardToken != "" {
- browserLaunchURL = serverAddr + "?token=" + url.QueryEscape(dashboardToken)
- } else {
- browserLaunchURL = serverAddr
- }
+ serverAddr = fmt.Sprintf("http://%s", net.JoinHostPort(openResult.ProbeHost, effectivePort))
+ browserLaunchURL = serverAddr + launcherBrowserLaunchSuffix(needsInitialSetup, localAutoLogin)
// Auto-open browser will be handled by the launcher runtime.
@@ -277,14 +654,19 @@ func main() {
apiHandler.TryAutoStartGateway()
}()
- // Start the Server in a goroutine
- server = &http.Server{Addr: addr, Handler: handler}
- go func() {
- logger.InfoC("web", fmt.Sprintf("Server listening on %s", addr))
- if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- logger.Fatalf("Server failed to start: %v", err)
- }
- }()
+ // Start the server(s) in goroutines.
+ servers = make([]*http.Server, 0, len(listeners))
+ for _, ln := range listeners {
+ srv := &http.Server{Handler: handler}
+ servers = append(servers, srv)
+
+ go func(s *http.Server, l net.Listener) {
+ logger.InfoC("web", fmt.Sprintf("Server listening on %s", l.Addr().String()))
+ if serveErr := s.Serve(l); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
+ logger.Fatalf("Server failed to start on %s: %v", l.Addr().String(), serveErr)
+ }
+ }(srv, ln)
+ }
defer shutdownApp()
diff --git a/web/backend/main_test.go b/web/backend/main_test.go
new file mode 100644
index 000000000..aea02927e
--- /dev/null
+++ b/web/backend/main_test.go
@@ -0,0 +1,422 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/netbind"
+ "github.com/sipeed/picoclaw/web/backend/middleware"
+)
+
+func TestShouldEnableLauncherFileLogging(t *testing.T) {
+ tests := []struct {
+ name string
+ enableConsole bool
+ debug bool
+ want bool
+ }{
+ {name: "gui mode", enableConsole: false, debug: false, want: true},
+ {name: "console mode", enableConsole: true, debug: false, want: false},
+ {name: "debug gui mode", enableConsole: false, debug: true, want: true},
+ {name: "debug console mode", enableConsole: true, debug: true, want: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := shouldEnableLauncherFileLogging(tt.enableConsole, tt.debug); got != tt.want {
+ t.Fatalf(
+ "shouldEnableLauncherFileLogging(%t, %t) = %t, want %t",
+ tt.enableConsole,
+ tt.debug,
+ got,
+ tt.want,
+ )
+ }
+ })
+ }
+}
+
+func TestShouldEnableLocalAutoLogin(t *testing.T) {
+ tests := []struct {
+ name string
+ noBrowser bool
+ probeHost string
+ wantEnable bool
+ }{
+ {name: "loopback localhost", probeHost: "localhost", wantEnable: true},
+ {name: "loopback ipv4", probeHost: "127.0.0.1", wantEnable: true},
+ {name: "loopback ipv6", probeHost: "::1", wantEnable: true},
+ {name: "browser disabled", noBrowser: true, probeHost: "localhost", wantEnable: false},
+ {name: "non-loopback host", probeHost: "192.168.1.50", wantEnable: false},
+ {name: "non-loopback hostname", probeHost: "example.com", wantEnable: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := shouldEnableLocalAutoLogin(tt.noBrowser, tt.probeHost); got != tt.wantEnable {
+ t.Fatalf(
+ "shouldEnableLocalAutoLogin(%t, %q) = %t, want %t",
+ tt.noBrowser,
+ tt.probeHost,
+ got,
+ tt.wantEnable,
+ )
+ }
+ })
+ }
+}
+
+func TestLauncherBrowserLaunchSuffix(t *testing.T) {
+ autoLogin, err := middleware.NewLauncherDashboardLocalAutoLogin(time.Minute)
+ if err != nil {
+ t.Fatalf("NewLauncherDashboardLocalAutoLogin() error = %v", err)
+ }
+
+ if got := launcherBrowserLaunchSuffix(true, autoLogin); got != middleware.LauncherDashboardSetupPath {
+ t.Fatalf("setup suffix = %q", got)
+ }
+ if got := launcherBrowserLaunchSuffix(false, autoLogin); !strings.HasPrefix(got, "/launcher-auto-login?nonce=") {
+ t.Fatalf("auto-login suffix = %q", got)
+ }
+ if got := launcherBrowserLaunchSuffix(false, nil); got != "" {
+ t.Fatalf("empty suffix = %q, want empty", got)
+ }
+}
+
+func TestResolveLauncherHostInput(t *testing.T) {
+ tests := []struct {
+ name string
+ flagHost string
+ explicitFlag bool
+ envHost string
+ wantHost string
+ wantActive bool
+ wantErr bool
+ }{
+ {
+ name: "flag host wins",
+ flagHost: "127.0.0.1",
+ explicitFlag: true,
+ envHost: "::",
+ wantHost: "127.0.0.1",
+ wantActive: true,
+ },
+ {name: "env host used when flag absent", envHost: "127.0.0.1,::1", wantHost: "127.0.0.1,::1", wantActive: true},
+ {name: "blank env ignored", envHost: " ", wantHost: "", wantActive: false},
+ {name: "invalid flag rejected", flagHost: "127.0.0.1, ", explicitFlag: true, wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotHost, gotActive, err := resolveLauncherHostInput(tt.flagHost, tt.explicitFlag, tt.envHost)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("resolveLauncherHostInput() err = %v, wantErr %t", err, tt.wantErr)
+ }
+ if tt.wantErr {
+ return
+ }
+ if gotHost != tt.wantHost {
+ t.Fatalf("resolveLauncherHostInput() host = %q, want %q", gotHost, tt.wantHost)
+ }
+ if gotActive != tt.wantActive {
+ t.Fatalf("resolveLauncherHostInput() active = %t, want %t", gotActive, tt.wantActive)
+ }
+ })
+ }
+}
+
+func TestLauncherConsoleHosts(t *testing.T) {
+ t.Run("default loopback shows localhost only", func(t *testing.T) {
+ hosts := launcherConsoleHostsWithLocalAddrs(
+ "",
+ false,
+ []string{"192.168.1.2", "10.0.0.8"},
+ []string{"2001:db8::1", "2001:db8::2"},
+ )
+ want := []string{"localhost"}
+ if strings.Join(hosts, ",") != strings.Join(want, ",") {
+ t.Fatalf("hosts = %#v, want %#v", hosts, want)
+ }
+ })
+
+ t.Run("explicit loopback hosts collapse to localhost", func(t *testing.T) {
+ tests := []struct {
+ name string
+ hostInput string
+ }{
+ {name: "ipv6 loopback", hostInput: "::1"},
+ {name: "ipv4 loopback", hostInput: "127.0.0.1"},
+ {name: "localhost", hostInput: "localhost"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ hosts := launcherConsoleHostsWithLocalAddrs(
+ tt.hostInput,
+ false,
+ []string{"192.168.1.2", "10.0.0.8"},
+ []string{"2001:db8::1", "2001:db8::2"},
+ )
+ want := []string{"localhost"}
+ if strings.Join(hosts, ",") != strings.Join(want, ",") {
+ t.Fatalf("hosts = %#v, want %#v", hosts, want)
+ }
+ })
+ }
+ })
+
+ t.Run("public wildcard shows localhost then ipv6 and ipv4", func(t *testing.T) {
+ hosts := launcherConsoleHostsWithLocalAddrs(
+ "",
+ true,
+ []string{"192.168.1.2", "10.0.0.8"},
+ []string{"2001:db8::1", "2001:db8::2"},
+ )
+ want := []string{"localhost", "2001:db8::1", "2001:db8::2", "192.168.1.2", "10.0.0.8"}
+ if strings.Join(hosts, ",") != strings.Join(want, ",") {
+ t.Fatalf("hosts = %#v, want %#v", hosts, want)
+ }
+ })
+
+ t.Run("explicit ipv6 any shows localhost then ipv6 variants", func(t *testing.T) {
+ hosts := launcherConsoleHostsWithLocalAddrs(
+ "::",
+ false,
+ []string{"192.168.1.2", "10.0.0.8"},
+ []string{"2001:db8::1", "2001:db8::2"},
+ )
+ want := []string{"localhost", "2001:db8::1", "2001:db8::2"}
+ if strings.Join(hosts, ",") != strings.Join(want, ",") {
+ t.Fatalf("hosts = %#v, want %#v", hosts, want)
+ }
+
+ for _, host := range hosts {
+ if host == "::1" || host == "127.0.0.1" || strings.HasPrefix(strings.ToLower(host), "fe80:") {
+ t.Fatalf("hosts = %#v, loopback IPs must not be displayed", hosts)
+ }
+ }
+ })
+
+ t.Run("explicit ipv4 any shows localhost then lan ipv4", func(t *testing.T) {
+ hosts := launcherConsoleHostsWithLocalAddrs(
+ "0.0.0.0",
+ false,
+ []string{"192.168.1.2", "10.0.0.8"},
+ []string{"2001:db8::1", "2001:db8::2"},
+ )
+ want := []string{"localhost", "192.168.1.2", "10.0.0.8"}
+ if strings.Join(hosts, ",") != strings.Join(want, ",") {
+ t.Fatalf("hosts = %#v, want %#v", hosts, want)
+ }
+ })
+
+ t.Run("explicit wildcard star shows localhost first", func(t *testing.T) {
+ hosts := launcherConsoleHostsWithLocalAddrs(
+ "*",
+ false,
+ []string{"192.168.1.2", "10.0.0.8"},
+ []string{"2001:db8::1", "2001:db8::2"},
+ )
+ want := []string{"localhost", "2001:db8::1", "2001:db8::2", "192.168.1.2", "10.0.0.8"}
+ if strings.Join(hosts, ",") != strings.Join(want, ",") {
+ t.Fatalf("hosts = %#v, want %#v", hosts, want)
+ }
+ })
+
+ t.Run("explicit multi-address binding without local tokens hides localhost", func(t *testing.T) {
+ hosts := launcherConsoleHostsWithLocalAddrs(
+ "192.168.1.2,10.0.0.8,2001:db8::1,2001:db8::2,fe80::1",
+ false,
+ []string{"192.168.1.2", "10.0.0.8"},
+ []string{"2001:db8::1", "2001:db8::2"},
+ )
+ want := []string{"192.168.1.2", "10.0.0.8", "2001:db8::1", "2001:db8::2"}
+ if strings.Join(hosts, ",") != strings.Join(want, ",") {
+ t.Fatalf("hosts = %#v, want %#v", hosts, want)
+ }
+ })
+}
+
+func TestWildcardAdvertiseIP(t *testing.T) {
+ tests := []struct {
+ name string
+ bindHosts []string
+ ipv4 string
+ ipv6 string
+ want string
+ }{
+ {
+ name: "ipv4 wildcard uses ipv4",
+ bindHosts: []string{"0.0.0.0"},
+ ipv4: "192.168.1.2",
+ ipv6: "2001:db8::1",
+ want: "192.168.1.2",
+ },
+ {
+ name: "dual wildcard prefers ipv6",
+ bindHosts: []string{"0.0.0.0", "::"},
+ ipv4: "192.168.1.2",
+ ipv6: "2001:db8::1",
+ want: "2001:db8::1",
+ },
+ {
+ name: "ipv6 wildcard uses ipv6",
+ bindHosts: []string{"::"},
+ ipv4: "192.168.1.2",
+ ipv6: "2001:db8::1",
+ want: "2001:db8::1",
+ },
+ {
+ name: "dual wildcard falls back to ipv4 when ipv6 missing",
+ bindHosts: []string{"0.0.0.0", "::"},
+ ipv4: "192.168.1.2",
+ ipv6: "",
+ want: "192.168.1.2",
+ },
+ {
+ name: "ipv6 wildcard without ipv6 does not advertise ipv4",
+ bindHosts: []string{"::"},
+ ipv4: "192.168.1.2",
+ ipv6: "",
+ want: "",
+ },
+ {
+ name: "non wildcard does not advertise",
+ bindHosts: []string{"127.0.0.1"},
+ ipv4: "192.168.1.2",
+ ipv6: "2001:db8::1",
+ want: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := wildcardAdvertiseIP(tt.bindHosts, tt.ipv4, tt.ipv6); got != tt.want {
+ t.Fatalf("wildcardAdvertiseIP(%#v, %q, %q) = %q, want %q", tt.bindHosts, tt.ipv4, tt.ipv6, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestOpenLauncherListeners_HonorsIPv6OnlyHost(t *testing.T) {
+ hasIPv4, hasIPv6 := netbind.DetectIPFamilies()
+ if !hasIPv6 {
+ t.Skip("IPv6 is unavailable in this environment")
+ }
+
+ result, err := openLauncherListeners("::", false, "0")
+ if err != nil {
+ t.Fatalf("openLauncherListeners() error = %v", err)
+ }
+ startLauncherTestHTTPServer(t, result.Listeners)
+ port := mustAtoi(t, result.Port)
+
+ requireLauncherHTTPReachable(t, "::1", port)
+ if hasIPv4 {
+ requireLauncherHTTPUnreachable(t, "127.0.0.1", port)
+ }
+}
+
+func TestOpenLauncherListeners_SupportsExplicitMultiHost(t *testing.T) {
+ hasIPv4, hasIPv6 := netbind.DetectIPFamilies()
+ if !hasIPv4 || !hasIPv6 {
+ t.Skip("dual-stack loopback is unavailable in this environment")
+ }
+
+ result, err := openLauncherListeners("127.0.0.1,::1", false, "0")
+ if err != nil {
+ t.Fatalf("openLauncherListeners() error = %v", err)
+ }
+ startLauncherTestHTTPServer(t, result.Listeners)
+ port := mustAtoi(t, result.Port)
+
+ requireLauncherHTTPReachable(t, "127.0.0.1", port)
+ requireLauncherHTTPReachable(t, "::1", port)
+}
+
+func startLauncherTestHTTPServer(t *testing.T, listeners []net.Listener) {
+ t.Helper()
+
+ server := &http.Server{
+ Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, "ok")
+ }),
+ }
+
+ errCh := make(chan error, len(listeners))
+ for _, listener := range listeners {
+ ln := listener
+ go func() {
+ errCh <- server.Serve(ln)
+ }()
+ }
+
+ t.Cleanup(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ _ = server.Shutdown(ctx)
+ for range listeners {
+ err := <-errCh
+ if err != nil && !errors.Is(err, http.ErrServerClosed) {
+ t.Fatalf("server.Serve() error = %v", err)
+ }
+ }
+ })
+}
+
+func requireLauncherHTTPReachable(t *testing.T, host string, port int) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for {
+ err := launcherHTTPGet(host, port)
+ if err == nil {
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("expected %s:%d to be reachable: %v", host, port, err)
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+}
+
+func requireLauncherHTTPUnreachable(t *testing.T, host string, port int) {
+ t.Helper()
+ if err := launcherHTTPGet(host, port); err == nil {
+ t.Fatalf("expected %s:%d to be unreachable", host, port)
+ }
+}
+
+func launcherHTTPGet(host string, port int) error {
+ client := &http.Client{
+ Timeout: 300 * time.Millisecond,
+ Transport: &http.Transport{
+ Proxy: nil,
+ },
+ }
+
+ resp, err := client.Get("http://" + net.JoinHostPort(host, strconv.Itoa(port)))
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return errors.New(resp.Status)
+ }
+ return nil
+}
+
+func mustAtoi(t *testing.T, value string) int {
+ t.Helper()
+ n, err := strconv.Atoi(value)
+ if err != nil {
+ t.Fatalf("Atoi(%q) error = %v", value, err)
+ }
+ return n
+}
diff --git a/web/backend/middleware/launcher_dashboard_auth.go b/web/backend/middleware/launcher_dashboard_auth.go
index 7e92fca22..fd59958a9 100644
--- a/web/backend/middleware/launcher_dashboard_auth.go
+++ b/web/backend/middleware/launcher_dashboard_auth.go
@@ -1,41 +1,88 @@
package middleware
import (
- "crypto/hmac"
- "crypto/sha256"
+ "crypto/rand"
"crypto/subtle"
- "encoding/hex"
+ "encoding/base64"
+ "errors"
"net/http"
+ "net/url"
"path"
"strings"
+ "sync"
"time"
)
-// LauncherDashboardCookieName is the HttpOnly cookie set after a successful token login.
+// LauncherDashboardCookieName is the HttpOnly cookie set after a successful password login.
const LauncherDashboardCookieName = "picoclaw_launcher_auth"
-// launcherDashboardSessionMaxAgeSec is the session cookie lifetime (7 days).
-const launcherDashboardSessionMaxAgeSec = 7 * 24 * 3600
+// launcherDashboardSessionMaxAgeSec is the dashboard session cookie lifetime (31 days).
+const launcherDashboardSessionMaxAgeSec = 31 * 24 * 3600
-const launcherSessionMACLabel = "picoclaw-launcher-v1"
+const (
+ launcherSessionCookieBytes = 32
+ launcherGrantNonceBytes = 32
+ // LauncherDashboardLocalAutoLoginPath is the one-shot local browser
+ // bootstrap endpoint used by the launcher-managed auto-open flow.
+ LauncherDashboardLocalAutoLoginPath = "/launcher-auto-login"
+ // LauncherDashboardSetupPath is the setup page used before the dashboard
+ // password is initialized.
+ LauncherDashboardSetupPath = "/launcher-setup"
+)
-// SessionCookieValue is the expected cookie value for the given signing key and dashboard token.
-func SessionCookieValue(signingKey []byte, dashboardToken string) string {
- mac := hmac.New(sha256.New, signingKey)
- _, _ = mac.Write([]byte(launcherSessionMACLabel))
- _, _ = mac.Write([]byte{0})
- _, _ = mac.Write([]byte(dashboardToken))
- return hex.EncodeToString(mac.Sum(nil))
+// NewLauncherDashboardSessionCookie creates the per-process session cookie value.
+func NewLauncherDashboardSessionCookie() (string, error) {
+ return randomURLToken(launcherSessionCookieBytes)
+}
+
+func randomURLToken(n int) (string, error) {
+ buf := make([]byte, n)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(buf), nil
}
// LauncherDashboardAuthConfig holds runtime material for dashboard access checks.
type LauncherDashboardAuthConfig struct {
ExpectedCookie string
- Token string
+ // LocalAutoLogin enables one-shot startup auto-login.
+ LocalAutoLogin *LauncherDashboardLocalAutoLogin
// SecureCookie sets the session cookie's Secure flag. If nil, DefaultLauncherDashboardSecureCookie is used.
SecureCookie func(*http.Request) bool
}
+// LauncherDashboardLocalAutoLogin is an in-memory, one-shot startup grant.
+// It is not a reusable credential; it only lets the launcher-opened browser
+// receive the current process session cookie.
+type LauncherDashboardLocalAutoLogin struct {
+ grant *launcherDashboardOneTimeGrant
+}
+
+type launcherDashboardOneTimeGrant struct {
+ mu sync.Mutex
+ expires time.Time
+ consumed bool
+ nonce string
+ now func() time.Time
+}
+
+// NewLauncherDashboardLocalAutoLogin creates a one-shot local auto-login grant.
+func NewLauncherDashboardLocalAutoLogin(ttl time.Duration) (*LauncherDashboardLocalAutoLogin, error) {
+ grant, err := newLauncherDashboardOneTimeGrant(ttl)
+ if err != nil {
+ return nil, err
+ }
+ return &LauncherDashboardLocalAutoLogin{
+ grant: grant,
+ }, nil
+}
+
+// URLPath returns the one-shot local auto-login URL path including its nonce.
+func (a *LauncherDashboardLocalAutoLogin) URLPath() string {
+ return launcherGrantQueryPath(LauncherDashboardLocalAutoLoginPath, a.grant)
+}
+
// DefaultLauncherDashboardSecureCookie mirrors typical production HTTPS detection (TLS or X-Forwarded-Proto).
func DefaultLauncherDashboardSecureCookie(r *http.Request) bool {
if r.TLS != nil {
@@ -44,7 +91,7 @@ func DefaultLauncherDashboardSecureCookie(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
-// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard token login.
+// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard password login.
func SetLauncherDashboardSessionCookie(
w http.ResponseWriter,
r *http.Request,
@@ -82,12 +129,13 @@ func ClearLauncherDashboardSessionCookie(w http.ResponseWriter, r *http.Request,
})
}
-// LauncherDashboardAuth requires a valid session cookie or Authorization: Bearer
-// before calling next. Public paths are login page and /api/auth/* handlers.
+// LauncherDashboardAuth requires a valid session cookie before calling next.
+// Public paths are login/setup pages and /api/auth/* handlers.
func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := canonicalAuthPath(r.URL.Path)
- if handled := tryLauncherQueryTokenLogin(w, r, p, cfg); handled {
+ if p == LauncherDashboardLocalAutoLoginPath {
+ handleLauncherLocalAutoLogin(w, r, cfg)
return
}
if isPublicLauncherDashboardPath(r.Method, p) {
@@ -105,45 +153,84 @@ func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) h
// canonicalAuthPath matches path cleaning used for routing decisions so
// prefixes like /assets/../ cannot bypass auth (CVE-class traversal).
-// tryLauncherQueryTokenLogin validates ?token= on GET only (non-/api), sets the session
-// cookie when correct, and redirects with 303 so the follow-up is a plain GET without side effects.
-// Invalid token is rejected like any other unauthenticated browser request.
-func tryLauncherQueryTokenLogin(
- w http.ResponseWriter,
- r *http.Request,
- canonicalPath string,
- cfg LauncherDashboardAuthConfig,
-) bool {
- if r.Method != http.MethodGet {
- return false
+func handleLauncherLocalAutoLogin(w http.ResponseWriter, r *http.Request, cfg LauncherDashboardAuthConfig) {
+ if validLauncherDashboardAuth(r, cfg) {
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
}
- if canonicalPath == "/api" || strings.HasPrefix(canonicalPath, "/api/") {
- return false
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ _, _ = w.Write([]byte("method not allowed"))
+ return
}
- qToken := strings.TrimSpace(r.URL.Query().Get("token"))
- if qToken == "" {
- return false
+ if r.Method == http.MethodHead {
+ rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath)
+ return
}
- if len(qToken) != len(cfg.Token) || subtle.ConstantTimeCompare([]byte(qToken), []byte(cfg.Token)) != 1 {
- rejectLauncherDashboardAuth(w, r, canonicalPath)
- return true
+ if cfg.LocalAutoLogin != nil && cfg.LocalAutoLogin.consume(r.URL.Query().Get("nonce")) {
+ SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie)
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
}
- SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie)
- http.Redirect(w, r, redirectAfterQueryTokenLogin(r, canonicalPath), http.StatusSeeOther)
- return true
+ rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath)
}
-func redirectAfterQueryTokenLogin(r *http.Request, canonicalPath string) string {
- if canonicalPath == "/launcher-login" {
- return "/"
+func (a *LauncherDashboardLocalAutoLogin) consume(nonce string) bool {
+ if a == nil || a.grant == nil {
+ return false
}
- q := r.URL.Query()
- q.Del("token")
- enc := q.Encode()
- if enc != "" {
- return canonicalPath + "?" + enc
+ return a.grant.use(nonce, nil) == nil
+}
+
+func newLauncherDashboardOneTimeGrant(ttl time.Duration) (*launcherDashboardOneTimeGrant, error) {
+ nonce, err := randomURLToken(launcherGrantNonceBytes)
+ if err != nil {
+ return nil, err
}
- return canonicalPath
+ return &launcherDashboardOneTimeGrant{
+ expires: time.Now().Add(ttl),
+ nonce: nonce,
+ now: time.Now,
+ }, nil
+}
+
+func launcherGrantQueryPath(basePath string, grant *launcherDashboardOneTimeGrant) string {
+ if grant == nil {
+ return basePath
+ }
+ return basePath + "?nonce=" + url.QueryEscape(grant.nonce)
+}
+
+// ErrInvalidLauncherDashboardGrant reports that an auto-login grant is missing,
+// expired, already consumed, or otherwise invalid.
+var ErrInvalidLauncherDashboardGrant = errors.New("invalid launcher dashboard grant")
+
+func (g *launcherDashboardOneTimeGrant) use(nonce string, fn func() error) error {
+ if g == nil {
+ return ErrInvalidLauncherDashboardGrant
+ }
+ if len(nonce) != len(g.nonce) ||
+ subtle.ConstantTimeCompare([]byte(nonce), []byte(g.nonce)) != 1 {
+ return ErrInvalidLauncherDashboardGrant
+ }
+
+ g.mu.Lock()
+ defer g.mu.Unlock()
+
+ now := time.Now
+ if g.now != nil {
+ now = g.now
+ }
+ if g.consumed || !now().Before(g.expires) {
+ return ErrInvalidLauncherDashboardGrant
+ }
+ if fn != nil {
+ if err := fn(); err != nil {
+ return err
+ }
+ }
+ g.consumed = true
+ return nil
}
func canonicalAuthPath(raw string) string {
@@ -173,6 +260,8 @@ func isPublicLauncherDashboardPath(method, p string) bool {
return method == http.MethodPost
case "/api/auth/status":
return method == http.MethodGet
+ case "/api/auth/setup":
+ return method == http.MethodPost
}
return false
}
@@ -183,7 +272,7 @@ func isPublicLauncherDashboardStatic(method, p string) bool {
if method != http.MethodGet && method != http.MethodHead {
return false
}
- if p == "/launcher-login" {
+ if p == "/launcher-login" || p == "/launcher-setup" {
return true
}
if strings.HasPrefix(p, "/assets/") {
@@ -204,18 +293,14 @@ func validLauncherDashboardAuth(r *http.Request, cfg LauncherDashboardAuthConfig
return true
}
}
- auth := r.Header.Get("Authorization")
- const prefix = "Bearer "
- if strings.HasPrefix(auth, prefix) {
- token := strings.TrimSpace(auth[len(prefix):])
- if len(token) == len(cfg.Token) && subtle.ConstantTimeCompare([]byte(token), []byte(cfg.Token)) == 1 {
- return true
- }
- }
return false
}
func rejectLauncherDashboardAuth(w http.ResponseWriter, r *http.Request, canonicalPath string) {
+ if canonicalPath == "/pico/ws" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
if strings.HasPrefix(canonicalPath, "/api/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
diff --git a/web/backend/middleware/launcher_dashboard_auth_test.go b/web/backend/middleware/launcher_dashboard_auth_test.go
index 1b919bf96..871b6f607 100644
--- a/web/backend/middleware/launcher_dashboard_auth_test.go
+++ b/web/backend/middleware/launcher_dashboard_auth_test.go
@@ -4,26 +4,37 @@ import (
"net/http"
"net/http/httptest"
"testing"
+ "time"
)
-func TestSessionCookieValue_Deterministic(t *testing.T) {
- key := make([]byte, 32)
- for i := range key {
- key[i] = byte(i)
+func TestNewLauncherDashboardSessionCookie(t *testing.T) {
+ a, err := NewLauncherDashboardSessionCookie()
+ if err != nil {
+ t.Fatalf("NewLauncherDashboardSessionCookie() error = %v", err)
}
- a := SessionCookieValue(key, "tok-a")
- b := SessionCookieValue(key, "tok-a")
- if a != b || a == "" {
- t.Fatalf("SessionCookieValue mismatch or empty: %q vs %q", a, b)
+ b, err := NewLauncherDashboardSessionCookie()
+ if err != nil {
+ t.Fatalf("NewLauncherDashboardSessionCookie() second error = %v", err)
}
- c := SessionCookieValue(key, "tok-b")
- if c == a {
- t.Fatal("SessionCookieValue should differ for different tokens")
+ if a == "" || b == "" {
+ t.Fatalf("session cookie values should be non-empty: %q %q", a, b)
+ }
+ if a == b {
+ t.Fatal("session cookie values should be random")
}
}
+func mustLocalAutoLogin(t *testing.T, ttl time.Duration) *LauncherDashboardLocalAutoLogin {
+ t.Helper()
+ autoLogin, err := NewLauncherDashboardLocalAutoLogin(ttl)
+ if err != nil {
+ t.Fatalf("NewLauncherDashboardLocalAutoLogin() error = %v", err)
+ }
+ return autoLogin
+}
+
func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) {
- cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"}
+ cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
})
@@ -34,12 +45,15 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) {
want int
}{
{http.MethodGet, "/launcher-login", http.StatusTeapot},
+ {http.MethodGet, "/launcher-setup", http.StatusTeapot},
{http.MethodGet, "/assets/index.js", http.StatusTeapot},
{http.MethodPost, "/api/auth/login", http.StatusTeapot},
{http.MethodGet, "/api/auth/status", http.StatusTeapot},
+ {http.MethodPost, "/api/auth/setup", http.StatusTeapot},
{http.MethodPost, "/api/auth/logout", http.StatusTeapot},
{http.MethodGet, "/api/auth/logout", http.StatusUnauthorized},
{http.MethodGet, "/api/config", http.StatusUnauthorized},
+ {http.MethodGet, "/pico/ws", http.StatusUnauthorized},
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(tc.method, tc.path, nil)
@@ -50,68 +64,143 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) {
}
}
-func TestLauncherDashboardAuth_URLTokenBootstrapGET(t *testing.T) {
- const tok = "secret"
- cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: tok}
+func TestLauncherDashboardAuth_QueryTokenDoesNotAuthenticate(t *testing.T) {
+ cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusTeapot)
+ t.Fatal("next handler should not run without session cookie")
})
h := LauncherDashboardAuth(cfg, next)
rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/?token="+tok, nil)
+ req := httptest.NewRequest(http.MethodGet, "/?token=secret", nil)
h.ServeHTTP(rec, req)
- if rec.Code != http.StatusSeeOther {
- t.Fatalf("GET /?token=valid: status = %d, want %d", rec.Code, http.StatusSeeOther)
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" {
+ t.Fatalf("GET /?token=secret: code=%d loc=%q", rec.Code, rec.Header().Get("Location"))
}
- if got := rec.Header().Get("Location"); got != "/" {
- t.Fatalf("Location = %q, want %q", got, "/")
+}
+
+func TestLauncherDashboardAuth_LocalAutoLogin(t *testing.T) {
+ const cookieVal = "session-cookie-value"
+ autoLogin := mustLocalAutoLogin(t, time.Minute)
+ cfg := LauncherDashboardAuthConfig{
+ ExpectedCookie: cookieVal,
+ LocalAutoLogin: autoLogin,
}
- if c := rec.Result().Cookies(); len(c) != 1 || c[0].Name != LauncherDashboardCookieName {
- t.Fatalf("expected one session cookie, got %#v", c)
+ next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+ h := LauncherDashboardAuth(cfg, next)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, LauncherDashboardLocalAutoLoginPath, nil)
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" ||
+ len(rec.Result().Cookies()) != 0 {
+ t.Fatalf(
+ "auto-login without nonce code=%d loc=%q cookies=%#v",
+ rec.Code,
+ rec.Header().Get("Location"),
+ rec.Result().Cookies(),
+ )
}
- rec1b := httptest.NewRecorder()
- req1b := httptest.NewRequest(http.MethodGet, "/config?token="+tok+"&keep=1", nil)
- h.ServeHTTP(rec1b, req1b)
- if rec1b.Code != http.StatusSeeOther {
- t.Fatalf("GET /config?token=valid: status = %d", rec1b.Code)
- }
- if got := rec1b.Header().Get("Location"); got != "/config?keep=1" {
- t.Fatalf("Location = %q, want /config?keep=1", got)
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, LauncherDashboardLocalAutoLoginPath+"?nonce=wrong", nil)
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" ||
+ len(rec.Result().Cookies()) != 0 {
+ t.Fatalf(
+ "auto-login with wrong nonce code=%d loc=%q cookies=%#v",
+ rec.Code,
+ rec.Header().Get("Location"),
+ rec.Result().Cookies(),
+ )
}
- recBad := httptest.NewRecorder()
- reqBad := httptest.NewRequest(http.MethodGet, "/?token=wrong", nil)
- h.ServeHTTP(recBad, reqBad)
- if recBad.Code != http.StatusFound || recBad.Header().Get("Location") != "/launcher-login" {
- t.Fatalf("GET /?token=invalid: code=%d loc=%q", recBad.Code, recBad.Header().Get("Location"))
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodHead, autoLogin.URLPath(), nil)
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" ||
+ len(rec.Result().Cookies()) != 0 {
+ t.Fatalf(
+ "auto-login HEAD code=%d loc=%q cookies=%#v",
+ rec.Code,
+ rec.Header().Get("Location"),
+ rec.Result().Cookies(),
+ )
}
- rec2 := httptest.NewRecorder()
- req2 := httptest.NewRequest(http.MethodGet, "/api/config?token="+tok, nil)
- h.ServeHTTP(rec2, req2)
- if rec2.Code != http.StatusUnauthorized {
- t.Fatalf("GET /api with token query: status = %d, want %d", rec2.Code, http.StatusUnauthorized)
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
+ t.Fatalf("local auto-login code=%d loc=%q", rec.Code, rec.Header().Get("Location"))
+ }
+ cookies := rec.Result().Cookies()
+ if len(cookies) != 1 || cookies[0].Name != LauncherDashboardCookieName || cookies[0].Value != cookieVal {
+ t.Fatalf("cookies = %#v", cookies)
+ }
+ if cookies[0].MaxAge != 31*24*3600 {
+ t.Fatalf("session cookie MaxAge = %d, want 31 days", cookies[0].MaxAge)
}
- rec3 := httptest.NewRecorder()
- req3 := httptest.NewRequest(http.MethodGet, "/?token=", nil)
- h.ServeHTTP(rec3, req3)
- if rec3.Code != http.StatusFound {
- t.Fatalf("GET /?token=empty: status = %d, want redirect", rec3.Code)
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, "/", nil)
+ req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal})
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("cookie auth after auto-login status = %d", rec.Code)
}
- recLogin := httptest.NewRecorder()
- reqLogin := httptest.NewRequest(http.MethodGet, "/launcher-login?token="+tok, nil)
- h.ServeHTTP(recLogin, reqLogin)
- if recLogin.Code != http.StatusSeeOther || recLogin.Header().Get("Location") != "/" {
- t.Fatalf("GET /launcher-login?token=valid: code=%d loc=%q", recLogin.Code, recLogin.Header().Get("Location"))
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
+ req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal})
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
+ t.Fatalf("auto-login path with existing session code=%d loc=%q", rec.Code, rec.Header().Get("Location"))
+ }
+
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" {
+ t.Fatalf("consumed auto-login code=%d loc=%q", rec.Code, rec.Header().Get("Location"))
+ }
+}
+
+func TestLauncherDashboardAuth_LocalAutoLoginRequiresValidNonceAndUnexpired(t *testing.T) {
+ const cookieVal = "session-cookie-value"
+ newHandler := func(autoLogin *LauncherDashboardLocalAutoLogin) http.Handler {
+ return LauncherDashboardAuth(LauncherDashboardAuthConfig{
+ ExpectedCookie: cookieVal,
+ LocalAutoLogin: autoLogin,
+ }, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ }
+
+ autoLogin := mustLocalAutoLogin(t, time.Minute)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
+ req.RemoteAddr = "192.168.1.50:12345"
+ req.Host = "192.168.1.50:18800"
+ newHandler(autoLogin).ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther || len(rec.Result().Cookies()) != 1 {
+ t.Fatalf("capability auto-login code=%d cookies=%#v", rec.Code, rec.Result().Cookies())
+ }
+
+ expired := mustLocalAutoLogin(t, -time.Second)
+ h := newHandler(expired)
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, expired.URLPath(), nil)
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound || len(rec.Result().Cookies()) != 0 {
+ t.Fatalf("expired auto-login code=%d cookies=%#v", rec.Code, rec.Result().Cookies())
}
}
func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) {
- cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"}
+ cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("next handler should not run without auth")
})
@@ -131,14 +220,9 @@ func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) {
}
}
-func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) {
- key := make([]byte, 32)
- for i := range key {
- key[i] = 0xab
- }
- token := "dashboard-secret-9"
- cookieVal := SessionCookieValue(key, token)
- cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal, Token: token}
+func TestLauncherDashboardAuth_CookieOnly(t *testing.T) {
+ cookieVal := "session-cookie-value"
+ cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
@@ -153,10 +237,29 @@ func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) {
}
rec2 := httptest.NewRecorder()
- req2 := httptest.NewRequest(http.MethodGet, "/", nil)
- req2.Header.Set("Authorization", "Bearer "+token)
+ req2 := httptest.NewRequest(http.MethodGet, "/api/config", nil)
+ req2.Header.Set("Authorization", "Bearer dashboard-secret-9")
h.ServeHTTP(rec2, req2)
- if rec2.Code != http.StatusOK {
- t.Fatalf("bearer auth: status = %d", rec2.Code)
+ if rec2.Code != http.StatusUnauthorized {
+ t.Fatalf("bearer auth should not be accepted: status = %d", rec2.Code)
+ }
+}
+
+func TestLauncherDashboardAuth_WebSocketUnauthorizedDoesNotRedirect(t *testing.T) {
+ cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
+ next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
+ t.Fatal("next handler should not run without auth")
+ })
+ h := LauncherDashboardAuth(cfg, next)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/pico/ws", nil)
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
+ }
+ if got := rec.Header().Get("Location"); got != "" {
+ t.Fatalf("Location = %q, want empty", got)
}
}
diff --git a/web/backend/middleware/middleware.go b/web/backend/middleware/middleware.go
index 5e0dfeb90..f9eb3149d 100644
--- a/web/backend/middleware/middleware.go
+++ b/web/backend/middleware/middleware.go
@@ -1,7 +1,9 @@
package middleware
import (
+ "bufio"
"fmt"
+ "net"
"net/http"
"runtime/debug"
"time"
@@ -44,6 +46,15 @@ func (rr *responseRecorder) Unwrap() http.ResponseWriter {
return rr.ResponseWriter
}
+// Hijack implements http.Hijacker so that WebSocket upgrades work through
+// the middleware layer.
+func (rr *responseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+ if hj, ok := rr.ResponseWriter.(http.Hijacker); ok {
+ return hj.Hijack()
+ }
+ return nil, nil, http.ErrNotSupported
+}
+
// Logger logs each HTTP request with method, path, status code, and duration.
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -60,6 +71,7 @@ func Recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
+ logger.RecoverPanicNoExit(err)
logger.ErrorC("http", fmt.Sprintf("panic recovered: %v\n%s", err, debug.Stack()))
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
}
diff --git a/web/backend/middleware/referrer_policy.go b/web/backend/middleware/referrer_policy.go
index 5ac066614..6cb14669d 100644
--- a/web/backend/middleware/referrer_policy.go
+++ b/web/backend/middleware/referrer_policy.go
@@ -2,8 +2,8 @@ package middleware
import "net/http"
-// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response so sensitive
-// query parameters (e.g. ?token= for dashboard bootstrap) are not leaked via the Referer header.
+// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response
+// so sensitive paths and query parameters are not leaked via the Referer header.
func ReferrerPolicyNoReferrer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Referrer-Policy", "no-referrer")
diff --git a/web/backend/systray.go b/web/backend/systray.go
index 744ea4611..41fea1fbe 100644
--- a/web/backend/systray.go
+++ b/web/backend/systray.go
@@ -1,4 +1,4 @@
-//go:build (!darwin && !freebsd) || cgo
+//go:build !android && ((!darwin && !freebsd) || cgo)
package main
@@ -6,7 +6,6 @@ import (
"fmt"
"fyne.io/systray"
- "github.com/atotto/clipboard"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/web/backend/utils"
@@ -24,7 +23,6 @@ func onReady() {
// Create menu items
mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip))
- mCopyTok := systray.AddMenuItem(T(MenuCopyToken), T(MenuCopyTokenHint))
mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip))
// Add version info under About menu
@@ -52,17 +50,6 @@ func onReady() {
logger.Errorf("Failed to open browser: %v", err)
}
- case <-mCopyTok.ClickedCh:
- if launcherDashboardTokenForClipboard == "" {
- logger.WarnC("web", "Dashboard token is empty; cannot copy")
- continue
- }
- if err := clipboard.WriteAll(launcherDashboardTokenForClipboard); err != nil {
- logger.Errorf("Failed to copy dashboard token: %v", err)
- } else {
- logger.InfoC("web", "Dashboard token copied to clipboard")
- }
-
case <-mVersion.ClickedCh:
// Version info - do nothing, just shows current version
diff --git a/web/backend/systray_stub_nocgo.go b/web/backend/systray_stub_nocgo.go
index 9e75e112a..41514feef 100644
--- a/web/backend/systray_stub_nocgo.go
+++ b/web/backend/systray_stub_nocgo.go
@@ -1,4 +1,4 @@
-//go:build (darwin || freebsd) && !cgo
+//go:build (darwin || freebsd || android) && !cgo
package main
diff --git a/web/backend/tray_offers_copy.go b/web/backend/tray_offers_copy.go
deleted file mode 100644
index 6b7d17412..000000000
--- a/web/backend/tray_offers_copy.go
+++ /dev/null
@@ -1,5 +0,0 @@
-//go:build (!darwin && !freebsd) || cgo
-
-package main
-
-func trayOffersDashboardTokenCopy() bool { return true }
diff --git a/web/backend/tray_offers_copy_stub.go b/web/backend/tray_offers_copy_stub.go
deleted file mode 100644
index 9312700f3..000000000
--- a/web/backend/tray_offers_copy_stub.go
+++ /dev/null
@@ -1,5 +0,0 @@
-//go:build (darwin || freebsd) && !cgo
-
-package main
-
-func trayOffersDashboardTokenCopy() bool { return false }
diff --git a/web/backend/utils/runtime.go b/web/backend/utils/runtime.go
index 772cd7ec0..8899a664b 100644
--- a/web/backend/utils/runtime.go
+++ b/web/backend/utils/runtime.go
@@ -7,18 +7,16 @@ import (
"os/exec"
"path/filepath"
"runtime"
+ "strings"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
)
// GetPicoclawHome returns the picoclaw home directory.
// Priority: $PICOCLAW_HOME > ~/.picoclaw
func GetPicoclawHome() string {
- if home := os.Getenv(config.EnvHome); home != "" {
- return home
- }
- home, _ := os.UserHomeDir()
- return filepath.Join(home, ".picoclaw")
+ return config.GetHome()
}
// GetDefaultConfigPath returns the default path to the picoclaw config file.
@@ -47,6 +45,7 @@ func FindPicoclawBinary() string {
}
if exe, err := os.Executable(); err == nil {
+ logger.Debugf("Trying to find picoclaw binary in %s", exe)
candidate := filepath.Join(filepath.Dir(exe), binaryName)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
@@ -56,18 +55,93 @@ func FindPicoclawBinary() string {
return "picoclaw"
}
-// GetLocalIP returns the local IP address of the machine.
-func GetLocalIP() string {
+func appendUniqueIP(addrs []string, seen map[string]struct{}, value string) []string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return addrs
+ }
+ if _, ok := seen[value]; ok {
+ return addrs
+ }
+ seen[value] = struct{}{}
+ return append(addrs, value)
+}
+
+// GetLocalIPv4s returns all non-loopback local IPv4 addresses.
+func GetLocalIPv4s() []string {
addrs, err := net.InterfaceAddrs()
if err != nil {
- return ""
+ return nil
}
+ results := make([]string, 0, 4)
+ seen := make(map[string]struct{}, 4)
for _, a := range addrs {
- if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
- return ipnet.IP.String()
+ ipnet, ok := a.(*net.IPNet)
+ if !ok || ipnet.IP == nil || ipnet.IP.IsLoopback() {
+ continue
+ }
+ if ip4 := ipnet.IP.To4(); ip4 != nil {
+ results = appendUniqueIP(results, seen, ip4.String())
}
}
- return ""
+ return results
+}
+
+func isDisplayGlobalIPv6(ip net.IP) bool {
+ if ip == nil || ip.IsLoopback() || ip.To4() != nil {
+ return false
+ }
+ ip = ip.To16()
+ if ip == nil {
+ return false
+ }
+ // Only show IPv6 global unicast addresses in 2000::/3.
+ return ip[0]&0xe0 == 0x20
+}
+
+// GetGlobalIPv6s returns all IPv6 global unicast addresses.
+func GetGlobalIPv6s() []string {
+ addrs, err := net.InterfaceAddrs()
+ if err != nil {
+ return nil
+ }
+ results := make([]string, 0, 4)
+ seen := make(map[string]struct{}, 4)
+ for _, a := range addrs {
+ ipnet, ok := a.(*net.IPNet)
+ if !ok || ipnet.IP == nil {
+ continue
+ }
+ ip := ipnet.IP
+ if !isDisplayGlobalIPv6(ip) {
+ continue
+ }
+ results = appendUniqueIP(results, seen, ip.String())
+ }
+ return results
+}
+
+// GetLocalIPv4 returns the first non-loopback local IPv4 address.
+func GetLocalIPv4() string {
+ addrs := GetLocalIPv4s()
+ if len(addrs) == 0 {
+ return ""
+ }
+ return addrs[0]
+}
+
+// GetLocalIPv6 returns the first IPv6 global unicast address.
+func GetLocalIPv6() string {
+ addrs := GetGlobalIPv6s()
+ if len(addrs) == 0 {
+ return ""
+ }
+ return addrs[0]
+}
+
+// GetLocalIP returns a non-loopback local IPv4 address for backward compatibility.
+func GetLocalIP() string {
+ return GetLocalIPv4()
}
// OpenBrowser automatically opens the given URL in the default browser.
diff --git a/web/frontend/eslint.config.js b/web/frontend/eslint.config.js
index bc9c64344..884649e41 100644
--- a/web/frontend/eslint.config.js
+++ b/web/frontend/eslint.config.js
@@ -22,10 +22,19 @@ export default defineConfig([
globals: globals.browser,
},
rules: {
+ "react-hooks/set-state-in-effect": "off",
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
+ {
+ files: ["src/routes/**/*.{ts,tsx}"],
+ rules: {
+ // TanStack Router route modules must export Route objects, so this rule
+ // produces false positives for framework-managed files.
+ "react-refresh/only-export-components": "off",
+ },
+ },
])
diff --git a/web/frontend/package.json b/web/frontend/package.json
index 8053d1f2a..bf3e7921b 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -3,6 +3,10 @@
"private": true,
"version": "0.0.0",
"type": "module",
+ "packageManager": "pnpm@10.33.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
@@ -15,51 +19,53 @@
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@tabler/icons-react": "^3.40.0",
- "@tailwindcss/vite": "^4.2.2",
- "@tanstack/react-query": "^5.90.21",
- "@tanstack/react-router": "^1.167.0",
- "@tanstack/react-router-devtools": "^1.163.3",
+ "@tailwindcss/vite": "^4.2.4",
+ "@tanstack/react-query": "^5.99.0",
+ "@tanstack/react-router": "^1.169.2",
+ "@tanstack/react-router-devtools": "^1.166.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
- "i18next": "^25.8.14",
+ "highlight.js": "^11.11.1",
+ "i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
- "jotai": "^2.18.1",
+ "jotai": "^2.19.1",
"radix-ui": "^1.4.3",
- "react": "^19.2.0",
- "react-dom": "^19.2.0",
- "react-i18next": "^16.5.8",
+ "react": "19.2.5",
+ "react-dom": "19.2.5",
+ "react-i18next": "^17.0.4",
"react-markdown": "^10.1.0",
"react-textarea-autosize": "^8.5.9",
+ "rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
- "shadcn": "^4.1.0",
+ "shadcn": "^4.3.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
- "tailwindcss": "^4.2.2",
+ "tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0",
"wrap-ansi": "^10.0.0"
},
"devDependencies": {
- "@eslint/js": "^9.39.4",
+ "@eslint/js": "^10.0.1",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/router-plugin": "^1.164.0",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
- "@types/node": "^25.5.0",
+ "@types/node": "^25.6.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
- "@typescript-eslint/eslint-plugin": "^8.57.1",
- "@vitejs/plugin-react": "^5.2.0",
- "eslint": "^9.39.4",
+ "@typescript-eslint/eslint-plugin": "^8.58.2",
+ "@vitejs/plugin-react": "^6.0.1",
+ "eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8",
- "eslint-plugin-react-hooks": "^7.0.1",
- "eslint-plugin-react-refresh": "^0.4.26",
- "globals": "^16.5.0",
- "prettier": "^3.8.1",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "globals": "^17.5.0",
+ "prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "~5.9.3",
- "typescript-eslint": "^8.57.1",
- "vite": "^7.3.1"
+ "typescript-eslint": "^8.59.1",
+ "vite": "^8.0.10"
}
}
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index edaf49ccc..78639de19 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -13,19 +13,19 @@ importers:
version: 5.2.8
'@tabler/icons-react':
specifier: ^3.40.0
- version: 3.40.0(react@19.2.4)
+ version: 3.41.1(react@19.2.5)
'@tailwindcss/vite':
- specifier: ^4.2.2
- version: 4.2.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
+ specifier: ^4.2.4
+ version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))
'@tanstack/react-query':
- specifier: ^5.90.21
- version: 5.91.2(react@19.2.4)
+ specifier: ^5.99.0
+ version: 5.99.0(react@19.2.5)
'@tanstack/react-router':
- specifier: ^1.167.0
- version: 1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ specifier: ^1.169.2
+ version: 1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@tanstack/react-router-devtools':
- specifier: ^1.163.3
- version: 1.166.9(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.5)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ specifier: ^1.166.13
+ version: 1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -35,33 +35,39 @@ importers:
dayjs:
specifier: ^1.11.20
version: 1.11.20
+ highlight.js:
+ specifier: ^11.11.1
+ version: 11.11.1
i18next:
- specifier: ^25.8.14
- version: 25.8.20(typescript@5.9.3)
+ specifier: ^26.0.8
+ version: 26.0.8(typescript@5.9.3)
i18next-browser-languagedetector:
specifier: ^8.2.1
version: 8.2.1
jotai:
- specifier: ^2.18.1
- version: 2.18.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4)
+ specifier: ^2.19.1
+ version: 2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5)
radix-ui:
specifier: ^1.4.3
- version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react:
- specifier: ^19.2.0
- version: 19.2.4
+ specifier: 19.2.5
+ version: 19.2.5
react-dom:
- specifier: ^19.2.0
- version: 19.2.4(react@19.2.4)
+ specifier: 19.2.5
+ version: 19.2.5(react@19.2.5)
react-i18next:
- specifier: ^16.5.8
- version: 16.5.8(i18next@25.8.20(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
+ specifier: ^17.0.4
+ version: 17.0.4(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)
react-markdown:
specifier: ^10.1.0
- version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
+ version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
react-textarea-autosize:
specifier: ^8.5.9
- version: 8.5.9(@types/react@19.2.14)(react@19.2.4)
+ version: 8.5.9(@types/react@19.2.14)(react@19.2.5)
+ rehype-highlight:
+ specifier: ^7.0.2
+ version: 7.0.2
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
@@ -72,17 +78,17 @@ importers:
specifier: ^4.0.1
version: 4.0.1
shadcn:
- specifier: ^4.1.0
- version: 4.1.0(@types/node@25.5.0)(typescript@5.9.3)
+ specifier: ^4.3.0
+ version: 4.3.0(@types/node@25.6.0)(typescript@5.9.3)
sonner:
specifier: ^2.0.7
- version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
tailwindcss:
- specifier: ^4.2.2
- version: 4.2.2
+ specifier: ^4.2.4
+ version: 4.2.4
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
@@ -91,20 +97,20 @@ importers:
version: 10.0.0
devDependencies:
'@eslint/js':
- specifier: ^9.39.4
- version: 9.39.4
+ specifier: ^10.0.1
+ version: 10.0.1(eslint@10.2.1(jiti@2.7.0))
'@tailwindcss/typography':
specifier: ^0.5.19
- version: 0.5.19(tailwindcss@4.2.2)
+ version: 0.5.19(tailwindcss@4.2.4)
'@tanstack/router-plugin':
specifier: ^1.164.0
- version: 1.166.14(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
+ version: 1.167.9(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))
'@trivago/prettier-plugin-sort-imports':
specifier: ^6.0.2
- version: 6.0.2(prettier@3.8.1)
+ version: 6.0.2(prettier@3.8.3)
'@types/node':
- specifier: ^25.5.0
- version: 25.5.0
+ specifier: ^25.6.0
+ version: 25.6.0
'@types/react':
specifier: ^19.2.7
version: 19.2.14
@@ -112,41 +118,41 @@ importers:
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@typescript-eslint/eslint-plugin':
- specifier: ^8.57.1
- version: 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ specifier: ^8.58.2
+ version: 8.58.2(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
'@vitejs/plugin-react':
- specifier: ^5.2.0
- version: 5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
+ specifier: ^6.0.1
+ version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))
eslint:
- specifier: ^9.39.4
- version: 9.39.4(jiti@2.6.1)
+ specifier: ^10.2.1
+ version: 10.2.1(jiti@2.7.0)
eslint-config-prettier:
specifier: ^10.1.8
- version: 10.1.8(eslint@9.39.4(jiti@2.6.1))
+ version: 10.1.8(eslint@10.2.1(jiti@2.7.0))
eslint-plugin-react-hooks:
- specifier: ^7.0.1
- version: 7.0.1(eslint@9.39.4(jiti@2.6.1))
+ specifier: ^7.1.1
+ version: 7.1.1(eslint@10.2.1(jiti@2.7.0))
eslint-plugin-react-refresh:
- specifier: ^0.4.26
- version: 0.4.26(eslint@9.39.4(jiti@2.6.1))
+ specifier: ^0.5.2
+ version: 0.5.2(eslint@10.2.1(jiti@2.7.0))
globals:
- specifier: ^16.5.0
- version: 16.5.0
+ specifier: ^17.5.0
+ version: 17.5.0
prettier:
- specifier: ^3.8.1
- version: 3.8.1
+ specifier: ^3.8.3
+ version: 3.8.3
prettier-plugin-tailwindcss:
specifier: ^0.7.2
- version: 0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1))(prettier@3.8.1)
+ version: 0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3))(prettier@3.8.3)
typescript:
specifier: ~5.9.3
version: 5.9.3
typescript-eslint:
- specifier: ^8.57.1
- version: 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ specifier: ^8.59.1
+ version: 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
vite:
- specifier: ^7.3.1
- version: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
+ specifier: ^8.0.10
+ version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)
packages:
@@ -255,18 +261,6 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
- '@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/plugin-transform-typescript@7.28.6':
resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==}
engines: {node: '>=6.9.0'}
@@ -295,16 +289,25 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
- '@dotenvx/dotenvx@1.57.0':
- resolution: {integrity: sha512-WsTEcqfHzKmLFZh3jLGd7o4iCkrIupp+qFH2FJUJtQXUh2GcOnLXD00DcrhlO4H8QSmaKnW9lugOEbrdpu25kA==}
+ '@dotenvx/dotenvx@1.61.0':
+ resolution: {integrity: sha512-utL3cpZoFzflyqUkjYbxYujI6STBTmO5LFn4bbin/NZnRWN6wQ7eErhr3/Vpa5h/jicPFC6kTa42r940mQftJQ==}
hasBin: true
- '@ecies/ciphers@0.2.5':
- resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==}
- engines: {bun: '>=1', deno: '>=2', node: '>=16'}
+ '@ecies/ciphers@0.2.6':
+ resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==}
+ engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'}
peerDependencies:
'@noble/ciphers': ^1.0.0
+ '@emnapi/core@1.10.0':
+ resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
+
+ '@emnapi/runtime@1.10.0':
+ resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
+
+ '@emnapi/wasi-threads@1.2.1':
+ resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
+
'@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'}
@@ -471,33 +474,34 @@ packages:
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
- '@eslint/config-array@0.21.2':
- resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/config-array@0.23.5':
+ resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
- '@eslint/config-helpers@0.4.2':
- resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/config-helpers@0.5.5':
+ resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
- '@eslint/core@0.17.0':
- resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/core@1.2.1':
+ resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
- '@eslint/eslintrc@3.3.5':
- resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/js@10.0.1':
+ resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ peerDependencies:
+ eslint: ^10.0.0
+ peerDependenciesMeta:
+ eslint:
+ optional: true
- '@eslint/js@9.39.4':
- resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/object-schema@3.0.5':
+ resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
- '@eslint/object-schema@2.1.7':
- resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
- '@eslint/plugin-kit@0.4.1':
- resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/plugin-kit@0.7.1':
+ resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
@@ -517,8 +521,8 @@ packages:
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
- '@hono/node-server@1.19.11':
- resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
+ '@hono/node-server@1.19.14':
+ resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
@@ -539,35 +543,35 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
- '@inquirer/ansi@1.0.2':
- resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==}
- engines: {node: '>=18'}
+ '@inquirer/ansi@2.0.5':
+ resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
- '@inquirer/confirm@5.1.21':
- resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==}
- engines: {node: '>=18'}
+ '@inquirer/confirm@6.0.11':
+ resolution: {integrity: sha512-pTpHjg0iEIRMYV/7oCZUMf27/383E6Wyhfc/MY+AVQGEoUobffIYWOK9YLP2XFRGz/9i6WlTQh1CkFVIo2Y7XA==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
- '@inquirer/core@10.3.2':
- resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==}
- engines: {node: '>=18'}
+ '@inquirer/core@11.1.8':
+ resolution: {integrity: sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
- '@inquirer/figures@1.0.15':
- resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==}
- engines: {node: '>=18'}
+ '@inquirer/figures@2.0.5':
+ resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
- '@inquirer/type@3.0.10':
- resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==}
- engines: {node: '>=18'}
+ '@inquirer/type@4.0.5':
+ resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
@@ -590,8 +594,8 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
- '@modelcontextprotocol/sdk@1.27.1':
- resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==}
+ '@modelcontextprotocol/sdk@1.29.0':
+ resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
engines: {node: '>=18'}
peerDependencies:
'@cfworker/json-schema': ^4.1.1
@@ -604,6 +608,12 @@ packages:
resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==}
engines: {node: '>=18'}
+ '@napi-rs/wasm-runtime@1.1.4':
+ resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1
+ '@emnapi/runtime': ^1.7.1
+
'@noble/ciphers@1.3.0':
resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
engines: {node: ^14.21.3 || >=16}
@@ -631,12 +641,18 @@ packages:
'@open-draft/deferred-promise@2.2.0':
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
+ '@open-draft/deferred-promise@3.0.0':
+ resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==}
+
'@open-draft/logger@0.3.0':
resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==}
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
+ '@oxc-project/types@0.127.0':
+ resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==}
+
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -1327,133 +1343,106 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
- '@rolldown/pluginutils@1.0.0-rc.3':
- resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
-
- '@rollup/rollup-android-arm-eabi@4.59.0':
- resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
- cpu: [arm]
- os: [android]
-
- '@rollup/rollup-android-arm64@4.59.0':
- resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==}
+ '@rolldown/binding-android-arm64@1.0.0-rc.17':
+ resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
- '@rollup/rollup-darwin-arm64@4.59.0':
- resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==}
+ '@rolldown/binding-darwin-arm64@1.0.0-rc.17':
+ resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
- '@rollup/rollup-darwin-x64@4.59.0':
- resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==}
+ '@rolldown/binding-darwin-x64@1.0.0-rc.17':
+ resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
- '@rollup/rollup-freebsd-arm64@4.59.0':
- resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==}
- cpu: [arm64]
- os: [freebsd]
-
- '@rollup/rollup-freebsd-x64@4.59.0':
- resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==}
+ '@rolldown/binding-freebsd-x64@1.0.0-rc.17':
+ resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
- '@rollup/rollup-linux-arm-gnueabihf@4.59.0':
- resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':
+ resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@rollup/rollup-linux-arm-musleabihf@4.59.0':
- resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
- cpu: [arm]
- os: [linux]
-
- '@rollup/rollup-linux-arm64-gnu@4.59.0':
- resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
+ resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
- '@rollup/rollup-linux-arm64-musl@4.59.0':
- resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
+ '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
+ resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
- '@rollup/rollup-linux-loong64-gnu@4.59.0':
- resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
- cpu: [loong64]
- os: [linux]
-
- '@rollup/rollup-linux-loong64-musl@4.59.0':
- resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
- cpu: [loong64]
- os: [linux]
-
- '@rollup/rollup-linux-ppc64-gnu@4.59.0':
- resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
+ resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
- '@rollup/rollup-linux-ppc64-musl@4.59.0':
- resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
- cpu: [ppc64]
- os: [linux]
-
- '@rollup/rollup-linux-riscv64-gnu@4.59.0':
- resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
- cpu: [riscv64]
- os: [linux]
-
- '@rollup/rollup-linux-riscv64-musl@4.59.0':
- resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
- cpu: [riscv64]
- os: [linux]
-
- '@rollup/rollup-linux-s390x-gnu@4.59.0':
- resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
+ '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
+ resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
- '@rollup/rollup-linux-x64-gnu@4.59.0':
- resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
+ '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
+ resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
- '@rollup/rollup-linux-x64-musl@4.59.0':
- resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
+ '@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
+ resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
- '@rollup/rollup-openbsd-x64@4.59.0':
- resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
- cpu: [x64]
- os: [openbsd]
-
- '@rollup/rollup-openharmony-arm64@4.59.0':
- resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==}
+ '@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
+ resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
- '@rollup/rollup-win32-arm64-msvc@4.59.0':
- resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==}
+ '@rolldown/binding-wasm32-wasi@1.0.0-rc.17':
+ resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [wasm32]
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17':
+ resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
- '@rollup/rollup-win32-ia32-msvc@4.59.0':
- resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==}
- cpu: [ia32]
- os: [win32]
-
- '@rollup/rollup-win32-x64-gnu@4.59.0':
- resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==}
+ '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':
+ resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
- '@rollup/rollup-win32-x64-msvc@4.59.0':
- resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==}
- cpu: [x64]
- os: [win32]
+ '@rolldown/pluginutils@1.0.0-rc.17':
+ resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==}
+
+ '@rolldown/pluginutils@1.0.0-rc.7':
+ resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
@@ -1462,73 +1451,77 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
- '@tabler/icons-react@3.40.0':
- resolution: {integrity: sha512-oO5+6QCnna4a//mYubx4euZfECtzQZFDGsDMIdzZUhbdyBCT+3bRVFBPueGIcemWld4Vb/0UQ39C/cmGfGylAg==}
+ '@tabler/icons-react@3.41.1':
+ resolution: {integrity: sha512-kUgweE+DJtAlMZVIns1FTDdcbpRVnkK7ZpUOXmoxy3JAF0rSHj0TcP4VHF14+gMJGnF+psH2Zt26BLT6owetBA==}
peerDependencies:
react: '>= 16'
- '@tabler/icons@3.40.0':
- resolution: {integrity: sha512-V/Q4VgNPKubRTiLdmWjV/zscYcj5IIk+euicUtaVVqF6luSC9rDngYWgST5/yh3Mrg/mYUwRv1YVTk71Jp0twQ==}
+ '@tabler/icons@3.41.1':
+ resolution: {integrity: sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q==}
- '@tailwindcss/node@4.2.2':
- resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
+ '@tailwindcss/node@4.2.4':
+ resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==}
- '@tailwindcss/oxide-android-arm64@4.2.2':
- resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==}
+ '@tailwindcss/oxide-android-arm64@4.2.4':
+ resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [android]
- '@tailwindcss/oxide-darwin-arm64@4.2.2':
- resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==}
+ '@tailwindcss/oxide-darwin-arm64@4.2.4':
+ resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [darwin]
- '@tailwindcss/oxide-darwin-x64@4.2.2':
- resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==}
+ '@tailwindcss/oxide-darwin-x64@4.2.4':
+ resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [darwin]
- '@tailwindcss/oxide-freebsd-x64@4.2.2':
- resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==}
+ '@tailwindcss/oxide-freebsd-x64@4.2.4':
+ resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [freebsd]
- '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
- resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==}
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4':
+ resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==}
engines: {node: '>= 20'}
cpu: [arm]
os: [linux]
- '@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
- resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==}
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.4':
+ resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==}
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==}
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.4':
+ resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==}
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==}
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.4':
+ resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
- '@tailwindcss/oxide-linux-x64-musl@4.2.2':
- resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
+ '@tailwindcss/oxide-linux-x64-musl@4.2.4':
+ resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
+ libc: [musl]
- '@tailwindcss/oxide-wasm32-wasi@4.2.2':
- resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
+ '@tailwindcss/oxide-wasm32-wasi@4.2.4':
+ resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
@@ -1539,20 +1532,20 @@ packages:
- '@emnapi/wasi-threads'
- tslib
- '@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
- resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==}
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.4':
+ resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [win32]
- '@tailwindcss/oxide-win32-x64-msvc@4.2.2':
- resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==}
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.4':
+ resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [win32]
- '@tailwindcss/oxide@4.2.2':
- resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==}
+ '@tailwindcss/oxide@4.2.4':
+ resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==}
engines: {node: '>= 20'}
'@tailwindcss/typography@0.5.19':
@@ -1560,8 +1553,8 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
- '@tailwindcss/vite@4.2.2':
- resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==}
+ '@tailwindcss/vite@4.2.4':
+ resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==}
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
@@ -1569,65 +1562,69 @@ packages:
resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==}
engines: {node: '>=20.19'}
- '@tanstack/query-core@5.91.2':
- resolution: {integrity: sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==}
+ '@tanstack/query-core@5.99.0':
+ resolution: {integrity: sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==}
- '@tanstack/react-query@5.91.2':
- resolution: {integrity: sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==}
+ '@tanstack/react-query@5.99.0':
+ resolution: {integrity: sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==}
peerDependencies:
react: ^18 || ^19
- '@tanstack/react-router-devtools@1.166.9':
- resolution: {integrity: sha512-O49eZmaeEKB5YnKH/qd61AbxV/lW8ICm4stfZ4GNQNpzQQ6rhPIB0p3PMZDIgX+6DoMivdNvLRmXAOOpzpIpDg==}
+ '@tanstack/react-router-devtools@1.166.13':
+ resolution: {integrity: sha512-6yKRFFJrEEOiGp5RAAuGCYsl81M4XAhJmLcu9PKj+HZle4A3dsP60lwHoqQYWHMK9nKKFkdXR+D8qxzxqtQbEA==}
engines: {node: '>=20.19'}
peerDependencies:
- '@tanstack/react-router': ^1.167.2
- '@tanstack/router-core': ^1.167.2
+ '@tanstack/react-router': ^1.168.15
+ '@tanstack/router-core': ^1.168.11
react: '>=18.0.0 || >=19.0.0'
react-dom: '>=18.0.0 || >=19.0.0'
peerDependenciesMeta:
'@tanstack/router-core':
optional: true
- '@tanstack/react-router@1.167.5':
- resolution: {integrity: sha512-s1nP6l/7BYZfSwhoNbB7/rUmZ07q/AvkmhBoiDQl3tgy5dpb9Q1qjtIapYdvCOrao1aA/QCaWqxcbGc2Ct1bvQ==}
+ '@tanstack/react-router@1.169.2':
+ resolution: {integrity: sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ==}
engines: {node: '>=20.19'}
peerDependencies:
react: '>=18.0.0 || >=19.0.0'
react-dom: '>=18.0.0 || >=19.0.0'
- '@tanstack/react-store@0.9.2':
- resolution: {integrity: sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==}
+ '@tanstack/react-store@0.9.3':
+ resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
- '@tanstack/router-core@1.167.5':
- resolution: {integrity: sha512-8fRgJ0zNJf77R4grCaJQ5Imatjyc4YT5v8rlsPkYYYeUlcFNLbuFRhLlAMdND9gRUMznpnbRDXngpTPgx2K7HQ==}
+ '@tanstack/router-core@1.168.7':
+ resolution: {integrity: sha512-z4UEdlzMrFaKBsG4OIxlZEm+wsYBtEp//fnX6kW18jhQpETNcM6u2SXNdX+bcIYp6AaR7ERS3SBENzjC/xxwQQ==}
engines: {node: '>=20.19'}
hasBin: true
- '@tanstack/router-devtools-core@1.166.9':
- resolution: {integrity: sha512-PNlA7GmOUX9wY7LUG709Pk3Lg33dfHBztQwzjzrOiOsuf4ggp2R6bwarF8nYGNjG79z/MaB5PN+5yvkCVk8jGw==}
+ '@tanstack/router-core@1.169.2':
+ resolution: {integrity: sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw==}
+ engines: {node: '>=20.19'}
+
+ '@tanstack/router-devtools-core@1.167.3':
+ resolution: {integrity: sha512-fJ1VMhyQgnoashTrP763c2HRc9kofgF61L7Jb3F6eTHAmCKtGVx8BRtiFt37sr3U0P0jmaaiiSPGP6nT5JtVNg==}
engines: {node: '>=20.19'}
peerDependencies:
- '@tanstack/router-core': ^1.167.2
+ '@tanstack/router-core': ^1.168.11
csstype: ^3.0.10
peerDependenciesMeta:
csstype:
optional: true
- '@tanstack/router-generator@1.166.13':
- resolution: {integrity: sha512-ALxSs6OzimiSgpOuIm+AXmc7eUx/oGPwSPpdQbpZ/kX7WHRh6qM7lv8DAN0K3jWcBpzF8eeOIdryWryX8gH+Yg==}
+ '@tanstack/router-generator@1.166.22':
+ resolution: {integrity: sha512-wQ7H8/Q2rmSPuaxWnurJ3DATNnqWV2tajxri9TSiW4QHsG7cWPD34+goeIinKG+GajJyEdfVpz6w/gRJXfbAPw==}
engines: {node: '>=20.19'}
- '@tanstack/router-plugin@1.166.14':
- resolution: {integrity: sha512-hypyj0qlsAbJf60/glmVYqSVwnRB4hKRrMCUsSXjrPdO2g6gs3z6xHmcWsHQ831C4G9+bSFEK9Uy5EjO3A4THQ==}
+ '@tanstack/router-plugin@1.167.9':
+ resolution: {integrity: sha512-h/VV05FEHd4PVyc5Zy8B3trWLcdLt/Pmp+mfifmBKGRw+MUtvdQKbBHhmy4ouOf67s5zDJMc+n8R3xgU7bDwFA==}
engines: {node: '>=20.19'}
hasBin: true
peerDependencies:
'@rsbuild/core': '>=1.0.2'
- '@tanstack/react-router': ^1.167.5
+ '@tanstack/react-router': ^1.168.8
vite: '>=5.0.0 || >=6.0.0 || >=7.0.0'
vite-plugin-solid: ^2.11.10
webpack: '>=5.92.0'
@@ -1647,8 +1644,8 @@ packages:
resolution: {integrity: sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw==}
engines: {node: '>=20.19'}
- '@tanstack/store@0.9.2':
- resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==}
+ '@tanstack/store@0.9.3':
+ resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
'@tanstack/virtual-file-routes@1.161.7':
resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==}
@@ -1677,21 +1674,15 @@ packages:
'@ts-morph/common@0.27.0':
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
- '@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==}
+ '@tybys/wasm-util@0.10.1':
+ resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
+ '@types/esrecurse@4.3.1':
+ resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
+
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@@ -1710,8 +1701,8 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
- '@types/node@25.5.0':
- resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
+ '@types/node@25.6.0':
+ resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
@@ -1721,6 +1712,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
+ '@types/set-cookie-parser@2.4.10':
+ resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==}
+
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
@@ -1733,73 +1727,133 @@ packages:
'@types/validate-npm-package-name@4.0.2':
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
- '@typescript-eslint/eslint-plugin@8.57.1':
- resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
+ '@typescript-eslint/eslint-plugin@8.58.2':
+ resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- '@typescript-eslint/parser': ^8.57.1
+ '@typescript-eslint/parser': ^8.58.2
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
- typescript: '>=4.8.4 <6.0.0'
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/parser@8.57.1':
- resolution: {integrity: sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==}
+ '@typescript-eslint/eslint-plugin@8.59.1':
+ resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.59.1
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/parser@8.59.1':
+ resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
- typescript: '>=4.8.4 <6.0.0'
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/project-service@8.57.1':
- resolution: {integrity: sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==}
+ '@typescript-eslint/project-service@8.58.2':
+ resolution: {integrity: sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- typescript: '>=4.8.4 <6.0.0'
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/scope-manager@8.57.1':
- resolution: {integrity: sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
- '@typescript-eslint/tsconfig-utils@8.57.1':
- resolution: {integrity: sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==}
+ '@typescript-eslint/project-service@8.59.1':
+ resolution: {integrity: sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- typescript: '>=4.8.4 <6.0.0'
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/type-utils@8.57.1':
- resolution: {integrity: sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==}
+ '@typescript-eslint/scope-manager@8.58.2':
+ resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/scope-manager@8.59.1':
+ resolution: {integrity: sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/tsconfig-utils@8.58.2':
+ resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/tsconfig-utils@8.59.1':
+ resolution: {integrity: sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/type-utils@8.58.2':
+ resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
- typescript: '>=4.8.4 <6.0.0'
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/types@8.57.1':
- resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
- '@typescript-eslint/typescript-estree@8.57.1':
- resolution: {integrity: sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- peerDependencies:
- typescript: '>=4.8.4 <6.0.0'
-
- '@typescript-eslint/utils@8.57.1':
- resolution: {integrity: sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==}
+ '@typescript-eslint/type-utils@8.59.1':
+ resolution: {integrity: sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
- typescript: '>=4.8.4 <6.0.0'
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/visitor-keys@8.57.1':
- resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==}
+ '@typescript-eslint/types@8.58.2':
+ resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/types@8.59.1':
+ resolution: {integrity: sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.58.2':
+ resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/typescript-estree@8.59.1':
+ resolution: {integrity: sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/utils@8.58.2':
+ resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/utils@8.59.1':
+ resolution: {integrity: sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/visitor-keys@8.58.2':
+ resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/visitor-keys@8.59.1':
+ resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+ deprecated: Potential CWE-502 - Update to 1.3.1 or higher
- '@vitejs/plugin-react@5.2.0':
- resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
+ '@vitejs/plugin-react@6.0.1':
+ resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
- vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+ '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
+ babel-plugin-react-compiler: ^1.0.0
+ vite: ^8.0.0
+ peerDependenciesMeta:
+ '@rolldown/plugin-babel':
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
@@ -1881,8 +1935,8 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
- baseline-browser-mapping@2.10.9:
- resolution: {integrity: sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==}
+ baseline-browser-mapping@2.10.17:
+ resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -1894,22 +1948,19 @@ packages:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
- brace-expansion@1.1.12:
- resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
+ brace-expansion@2.0.3:
+ resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==}
- brace-expansion@2.0.2:
- resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
-
- brace-expansion@5.0.4:
- resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==}
+ brace-expansion@5.0.5:
+ resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
- browserslist@4.28.1:
- resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
+ browserslist@4.28.2:
+ resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
@@ -1933,16 +1984,12 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
- caniuse-lite@1.0.30001780:
- resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==}
+ caniuse-lite@1.0.30001787:
+ resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
- chalk@4.1.2:
- resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
- engines: {node: '>=10'}
-
chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
@@ -2007,11 +2054,8 @@ packages:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
- concat-map@0.0.1:
- resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
-
- content-disposition@1.0.1:
- resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==}
+ content-disposition@1.1.0:
+ resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
engines: {node: '>=18'}
content-type@1.0.5:
@@ -2024,6 +2068,9 @@ packages:
cookie-es@2.0.0:
resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==}
+ cookie-es@3.1.1:
+ resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
+
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
@@ -2125,12 +2172,12 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
- diff@8.0.3:
- resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
+ diff@8.0.4:
+ resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
- dotenv@17.3.1:
- resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==}
+ dotenv@17.4.2:
+ resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'}
dunder-proto@1.0.1:
@@ -2144,8 +2191,8 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
- electron-to-chromium@1.5.321:
- resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==}
+ electron-to-chromium@1.5.334:
+ resolution: {integrity: sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -2157,8 +2204,8 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
- enhanced-resolve@5.20.1:
- resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
+ enhanced-resolve@5.21.0:
+ resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
engines: {node: '>=10.13.0'}
entities@6.0.1:
@@ -2210,36 +2257,32 @@ packages:
peerDependencies:
eslint: '>=7.0.0'
- eslint-plugin-react-hooks@7.0.1:
- resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==}
+ eslint-plugin-react-hooks@7.1.1:
+ resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
engines: {node: '>=18'}
peerDependencies:
- eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
+ eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
- eslint-plugin-react-refresh@0.4.26:
- resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==}
+ eslint-plugin-react-refresh@0.5.2:
+ resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==}
peerDependencies:
- eslint: '>=8.40'
+ eslint: ^9 || ^10
- eslint-scope@8.4.0:
- resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ eslint-scope@9.1.2:
+ resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint-visitor-keys@3.4.3:
resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
- eslint-visitor-keys@4.2.1:
- resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
eslint-visitor-keys@5.0.1:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
- eslint@9.39.4:
- resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ eslint@10.2.1:
+ resolution: {integrity: sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
hasBin: true
peerDependencies:
jiti: '*'
@@ -2247,9 +2290,9 @@ packages:
jiti:
optional: true
- espree@10.4.0:
- resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ espree@11.2.0:
+ resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
esprima@4.0.1:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
@@ -2295,8 +2338,8 @@ packages:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
- express-rate-limit@8.3.1:
- resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==}
+ express-rate-limit@8.3.2:
+ resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
@@ -2321,9 +2364,18 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+ fast-string-truncated-width@3.0.3:
+ resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
+
+ fast-string-width@3.0.2:
+ resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
+
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
+ fast-wrap-ansi@0.2.0:
+ resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==}
+
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -2430,8 +2482,8 @@ packages:
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
engines: {node: '>=18'}
- get-tsconfig@4.13.6:
- resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
+ get-tsconfig@4.13.7:
+ resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
@@ -2441,12 +2493,8 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
- globals@14.0.0:
- resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
- engines: {node: '>=18'}
-
- globals@16.5.0:
- resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
+ globals@17.5.0:
+ resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==}
engines: {node: '>=18'}
goober@2.1.18:
@@ -2461,14 +2509,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
- graphql@16.13.1:
- resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==}
+ graphql@16.13.2:
+ resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
- has-flag@4.0.0:
- resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
- engines: {node: '>=8'}
-
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
@@ -2480,6 +2524,9 @@ packages:
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
+ hast-util-is-element@3.0.0:
+ resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
+
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
@@ -2495,14 +2542,17 @@ packages:
hast-util-to-parse5@8.0.1:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
+ hast-util-to-text@4.0.2:
+ resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
+
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
- headers-polyfill@4.0.3:
- resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
+ headers-polyfill@5.0.1:
+ resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==}
hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
@@ -2510,8 +2560,12 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
- hono@4.12.8:
- resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==}
+ highlight.js@11.11.1:
+ resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
+ engines: {node: '>=12.0.0'}
+
+ hono@4.12.14:
+ resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==}
engines: {node: '>=16.9.0'}
html-parse-stringify@3.0.1:
@@ -2542,10 +2596,10 @@ packages:
i18next-browser-languagedetector@8.2.1:
resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==}
- i18next@25.8.20:
- resolution: {integrity: sha512-xjo9+lbX/P1tQt3xpO2rfJiBppNfUnNIPKgCvNsTKsvTOCro1Qr/geXVg1N47j5ScOSaXAPq8ET93raK3Rr06A==}
+ i18next@26.0.8:
+ resolution: {integrity: sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==}
peerDependencies:
- typescript: ^5
+ typescript: ^5 || ^6
peerDependenciesMeta:
typescript:
optional: true
@@ -2675,8 +2729,8 @@ packages:
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
engines: {node: '>=16'}
- isbot@5.1.36:
- resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==}
+ isbot@5.1.40:
+ resolution: {integrity: sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ==}
engines: {node: '>=18'}
isexe@2.0.0:
@@ -2689,15 +2743,15 @@ packages:
javascript-natural-sort@0.7.1:
resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==}
- jiti@2.6.1:
- resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
+ jiti@2.7.0:
+ resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
jose@6.2.2:
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
- jotai@2.18.1:
- resolution: {integrity: sha512-e0NOzK+yRFwHo7DOp0DS0Ycq74KMEAObDWFGmfEL28PD9nLqBTt3/Ug7jf9ca72x0gC9LQZG9zH+0ISICmy3iA==}
+ jotai@2.19.1:
+ resolution: {integrity: sha512-sqm9lVZiqBHZH8aSRk32DSiZDHY3yUIlulXYn9GQj7/LvoUdYXSMti7ZPJGo+6zjzKFt5a25k/I6iBCi43PJcw==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@babel/core': '>=7.0.0'
@@ -2802,24 +2856,28 @@ packages:
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==}
@@ -2847,9 +2905,6 @@ packages:
lodash-es@4.17.23:
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
- lodash.merge@4.6.2:
- resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
-
log-symbols@6.0.0:
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
engines: {node: '>=18'}
@@ -2857,6 +2912,9 @@ packages:
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+ lowlight@3.3.0:
+ resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
+
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -3034,13 +3092,10 @@ packages:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
- minimatch@10.2.4:
- resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
+ minimatch@10.2.5:
+ resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
- minimatch@3.1.5:
- resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
-
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -3051,8 +3106,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
- msw@2.12.13:
- resolution: {integrity: sha512-9CV2mXT9+z0J26MQDfEZZkj/psJ5Er/w0w+t95FWdaGH/DTlhNZBx8vBO5jSYv8AZEnl3ouX+AaTT68KXdAIag==}
+ msw@2.13.4:
+ resolution: {integrity: sha512-fPlKBeFe+8rpcyR3umUmmHuNwu6gc6T3STvkgEa9WDX/HEgal9wDeflpCUAIRtmvaLZM2igfI5y1bZ9G5J26KA==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
@@ -3061,9 +3116,9 @@ packages:
typescript:
optional: true
- mute-stream@2.0.0:
- resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
- engines: {node: ^18.17.0 || >=20.5.0}
+ mute-stream@3.0.0:
+ resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
+ engines: {node: ^20.17.0 || >=22.9.0}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
@@ -3086,8 +3141,8 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
- node-releases@2.0.36:
- resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
+ node-releases@2.0.37:
+ resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
@@ -3197,8 +3252,8 @@ packages:
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
- path-to-regexp@8.3.0:
- resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
+ path-to-regexp@8.4.2:
+ resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -3206,12 +3261,12 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
- picomatch@2.3.1:
- resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+ picomatch@2.3.2:
+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
- picomatch@4.0.3:
- resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pkce-challenge@5.0.1:
@@ -3226,8 +3281,8 @@ packages:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
engines: {node: '>=4'}
- postcss@8.5.8:
- resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
+ postcss@8.5.10:
+ resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
engines: {node: ^10 || ^12 || >=14}
powershell-utils@0.1.0:
@@ -3293,8 +3348,8 @@ packages:
prettier-plugin-svelte:
optional: true
- prettier@3.8.1:
- resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
+ prettier@3.8.3:
+ resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
engines: {node: '>=14'}
hasBin: true
@@ -3317,8 +3372,8 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
- qs@6.15.0:
- resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
+ qs@6.15.1:
+ resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
engines: {node: '>=0.6'}
queue-microtask@1.2.3:
@@ -3345,19 +3400,19 @@ packages:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
- react-dom@19.2.4:
- resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
+ react-dom@19.2.5:
+ resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
peerDependencies:
- react: ^19.2.4
+ react: ^19.2.5
- react-i18next@16.5.8:
- resolution: {integrity: sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==}
+ react-i18next@17.0.4:
+ resolution: {integrity: sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==}
peerDependencies:
- i18next: '>= 25.6.2'
+ i18next: '>= 26.0.1'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
- typescript: ^5
+ typescript: ^5 || ^6
peerDependenciesMeta:
react-dom:
optional: true
@@ -3372,10 +3427,6 @@ packages:
'@types/react': '>=18'
react: '>=18'
- react-refresh@0.18.0:
- resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
- engines: {node: '>=0.10.0'}
-
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
@@ -3412,8 +3463,8 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
- react@19.2.4:
- resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
+ react@19.2.5:
+ resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'}
readdirp@3.6.0:
@@ -3424,6 +3475,9 @@ packages:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
+ rehype-highlight@7.0.2:
+ resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
+
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
@@ -3461,16 +3515,16 @@ packages:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
- rettime@0.10.1:
- resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==}
+ rettime@0.11.7:
+ resolution: {integrity: sha512-DoAm1WjR1eH7z8sHPtvvUMIZh4/CSKkGCz6CxPqOrEAnOGtOuHSnSE9OC+razqxKuf4ub7pAYyl/vZV0vGs5tg==}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
- rollup@4.59.0:
- resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
- engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ rolldown@1.0.0-rc.17:
+ resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
router@2.2.0:
@@ -3509,19 +3563,32 @@ packages:
peerDependencies:
seroval: ^1.0
+ seroval-plugins@1.5.4:
+ resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ seroval: ^1.0
+
seroval@1.5.1:
resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
engines: {node: '>=10'}
+ seroval@1.5.4:
+ resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==}
+ engines: {node: '>=10'}
+
serve-static@2.2.1:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
+ set-cookie-parser@3.1.0:
+ resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
+
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
- shadcn@4.1.0:
- resolution: {integrity: sha512-3zETJ+0Ezj69FS6RL0HOkLKKAR5yXisXx1iISJdfLQfrUqj/VIQlanQi1Ukk+9OE+XHZVj4FQNTBSfbr2CyCYg==}
+ shadcn@4.3.0:
+ resolution: {integrity: sha512-7vhnBh2LVLyxOd1ZQWwXv7OATCnQcxdqc8FbZdNigZriNOwDsHklQmPpvPt1jcrFK5mzMI+cyuAYv8WzERx2Og==}
hasBin: true
shebang-command@2.0.0:
@@ -3532,8 +3599,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
- side-channel-list@1.0.0:
- resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
+ side-channel-list@1.0.1:
+ resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
side-channel-map@1.0.1:
@@ -3629,20 +3696,12 @@ packages:
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
engines: {node: '>=18'}
- strip-json-comments@3.1.1:
- resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
- engines: {node: '>=8'}
-
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
style-to-object@1.0.14:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
- supports-color@7.2.0:
- resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
- engines: {node: '>=8'}
-
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
@@ -3650,28 +3709,25 @@ packages:
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
- tailwindcss@4.2.2:
- resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
+ tailwindcss@4.2.4:
+ resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==}
- tapable@2.3.0:
- resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
+ tapable@2.3.3:
+ resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
- tiny-warning@1.0.3:
- resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
-
- tinyglobby@0.2.15:
- resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
+ tinyglobby@0.2.16:
+ resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
- tldts-core@7.0.26:
- resolution: {integrity: sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==}
+ tldts-core@7.0.28:
+ resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==}
- tldts@7.0.26:
- resolution: {integrity: sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==}
+ tldts@7.0.28:
+ resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==}
hasBin: true
to-regex-range@5.0.1:
@@ -3728,20 +3784,20 @@ packages:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
- typescript-eslint@8.57.1:
- resolution: {integrity: sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==}
+ typescript-eslint@8.59.1:
+ resolution: {integrity: sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
- typescript: '>=4.8.4 <6.0.0'
+ typescript: '>=4.8.4 <6.1.0'
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
- undici-types@7.18.2:
- resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
+ undici-types@7.19.2:
+ resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
@@ -3750,6 +3806,9 @@ packages:
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
+ unist-util-find-after@5.0.0:
+ resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
+
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
@@ -3861,15 +3920,16 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
- vite@7.3.1:
- resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
+ vite@8.0.10:
+ resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
+ '@vitejs/devtools': ^0.1.0
+ esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
- lightningcss: ^1.21.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
@@ -3880,12 +3940,14 @@ packages:
peerDependenciesMeta:
'@types/node':
optional: true
+ '@vitejs/devtools':
+ optional: true
+ esbuild:
+ optional: true
jiti:
optional: true
less:
optional: true
- lightningcss:
- optional: true
sass:
optional: true
sass-embedded:
@@ -3933,10 +3995,6 @@ packages:
resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==}
engines: {node: '>=20'}
- wrap-ansi@6.2.0:
- resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
- engines: {node: '>=8'}
-
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -3967,18 +4025,18 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
- yoctocolors-cjs@2.1.3:
- resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
- engines: {node: '>=18'}
+ yocto-spinner@1.1.0:
+ resolution: {integrity: sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==}
+ engines: {node: '>=18.19'}
yoctocolors@2.1.2:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
- zod-to-json-schema@3.25.1:
- resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
+ zod-to-json-schema@3.25.2:
+ resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
peerDependencies:
- zod: ^3.25 || ^4
+ zod: ^3.25.28 || ^4
zod-validation-error@4.0.2:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
@@ -4041,7 +4099,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.29.0
'@babel/helper-validator-option': 7.27.1
- browserslist: 4.28.1
+ browserslist: 4.28.2
lru-cache: 5.1.1
semver: 6.3.1
@@ -4138,16 +4196,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@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/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
@@ -4195,22 +4243,39 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
- '@dotenvx/dotenvx@1.57.0':
+ '@dotenvx/dotenvx@1.61.0':
dependencies:
commander: 11.1.0
- dotenv: 17.3.1
+ dotenv: 17.4.2
eciesjs: 0.4.18
execa: 5.1.1
- fdir: 6.5.0(picomatch@4.0.3)
+ fdir: 6.5.0(picomatch@4.0.4)
ignore: 5.3.2
object-treeify: 1.1.33
- picomatch: 4.0.3
+ picomatch: 4.0.4
which: 4.0.0
+ yocto-spinner: 1.1.0
- '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)':
+ '@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)':
dependencies:
'@noble/ciphers': 1.3.0
+ '@emnapi/core@1.10.0':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.1
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/runtime@1.10.0':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/wasi-threads@1.2.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@esbuild/aix-ppc64@0.27.4':
optional: true
@@ -4289,50 +4354,38 @@ snapshots:
'@esbuild/win32-x64@0.27.4':
optional: true
- '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
+ '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1(jiti@2.7.0))':
dependencies:
- eslint: 9.39.4(jiti@2.6.1)
+ eslint: 10.2.1(jiti@2.7.0)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
- '@eslint/config-array@0.21.2':
+ '@eslint/config-array@0.23.5':
dependencies:
- '@eslint/object-schema': 2.1.7
+ '@eslint/object-schema': 3.0.5
debug: 4.4.3
- minimatch: 3.1.5
+ minimatch: 10.2.5
transitivePeerDependencies:
- supports-color
- '@eslint/config-helpers@0.4.2':
+ '@eslint/config-helpers@0.5.5':
dependencies:
- '@eslint/core': 0.17.0
+ '@eslint/core': 1.2.1
- '@eslint/core@0.17.0':
+ '@eslint/core@1.2.1':
dependencies:
'@types/json-schema': 7.0.15
- '@eslint/eslintrc@3.3.5':
+ '@eslint/js@10.0.1(eslint@10.2.1(jiti@2.7.0))':
+ optionalDependencies:
+ eslint: 10.2.1(jiti@2.7.0)
+
+ '@eslint/object-schema@3.0.5': {}
+
+ '@eslint/plugin-kit@0.7.1':
dependencies:
- ajv: 6.14.0
- debug: 4.4.3
- espree: 10.4.0
- globals: 14.0.0
- ignore: 5.3.2
- import-fresh: 3.3.1
- js-yaml: 4.1.1
- minimatch: 3.1.5
- strip-json-comments: 3.1.1
- transitivePeerDependencies:
- - supports-color
-
- '@eslint/js@9.39.4': {}
-
- '@eslint/object-schema@2.1.7': {}
-
- '@eslint/plugin-kit@0.4.1':
- dependencies:
- '@eslint/core': 0.17.0
+ '@eslint/core': 1.2.1
levn: 0.4.1
'@floating-ui/core@1.7.5':
@@ -4344,19 +4397,19 @@ snapshots:
'@floating-ui/core': 1.7.5
'@floating-ui/utils': 0.2.11
- '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@floating-ui/react-dom@2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@floating-ui/dom': 1.7.6
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
'@floating-ui/utils@0.2.11': {}
'@fontsource-variable/inter@5.2.8': {}
- '@hono/node-server@1.19.11(hono@4.12.8)':
+ '@hono/node-server@1.19.14(hono@4.12.14)':
dependencies:
- hono: 4.12.8
+ hono: 4.12.14
'@humanfs/core@0.19.1': {}
@@ -4369,33 +4422,32 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
- '@inquirer/ansi@1.0.2': {}
+ '@inquirer/ansi@2.0.5': {}
- '@inquirer/confirm@5.1.21(@types/node@25.5.0)':
+ '@inquirer/confirm@6.0.11(@types/node@25.6.0)':
dependencies:
- '@inquirer/core': 10.3.2(@types/node@25.5.0)
- '@inquirer/type': 3.0.10(@types/node@25.5.0)
+ '@inquirer/core': 11.1.8(@types/node@25.6.0)
+ '@inquirer/type': 4.0.5(@types/node@25.6.0)
optionalDependencies:
- '@types/node': 25.5.0
+ '@types/node': 25.6.0
- '@inquirer/core@10.3.2(@types/node@25.5.0)':
+ '@inquirer/core@11.1.8(@types/node@25.6.0)':
dependencies:
- '@inquirer/ansi': 1.0.2
- '@inquirer/figures': 1.0.15
- '@inquirer/type': 3.0.10(@types/node@25.5.0)
+ '@inquirer/ansi': 2.0.5
+ '@inquirer/figures': 2.0.5
+ '@inquirer/type': 4.0.5(@types/node@25.6.0)
cli-width: 4.1.0
- mute-stream: 2.0.0
+ fast-wrap-ansi: 0.2.0
+ mute-stream: 3.0.0
signal-exit: 4.1.0
- wrap-ansi: 6.2.0
- yoctocolors-cjs: 2.1.3
optionalDependencies:
- '@types/node': 25.5.0
+ '@types/node': 25.6.0
- '@inquirer/figures@1.0.15': {}
+ '@inquirer/figures@2.0.5': {}
- '@inquirer/type@3.0.10(@types/node@25.5.0)':
+ '@inquirer/type@4.0.5(@types/node@25.6.0)':
optionalDependencies:
- '@types/node': 25.5.0
+ '@types/node': 25.6.0
'@jridgewell/gen-mapping@0.3.13':
dependencies:
@@ -4416,9 +4468,9 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
- '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)':
+ '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
dependencies:
- '@hono/node-server': 1.19.11(hono@4.12.8)
+ '@hono/node-server': 1.19.14(hono@4.12.14)
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
content-type: 1.0.5
@@ -4427,14 +4479,14 @@ snapshots:
eventsource: 3.0.7
eventsource-parser: 3.0.6
express: 5.2.1
- express-rate-limit: 8.3.1(express@5.2.1)
- hono: 4.12.8
+ express-rate-limit: 8.3.2(express@5.2.1)
+ hono: 4.12.14
jose: 6.2.2
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
zod: 3.25.76
- zod-to-json-schema: 3.25.1(zod@3.25.76)
+ zod-to-json-schema: 3.25.2(zod@3.25.76)
transitivePeerDependencies:
- supports-color
@@ -4447,6 +4499,13 @@ snapshots:
outvariant: 1.4.3
strict-event-emitter: 0.5.1
+ '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@tybys/wasm-util': 0.10.1
+ optional: true
+
'@noble/ciphers@1.3.0': {}
'@noble/curves@1.9.7':
@@ -4469,6 +4528,8 @@ snapshots:
'@open-draft/deferred-promise@2.2.0': {}
+ '@open-draft/deferred-promise@3.0.0': {}
+
'@open-draft/logger@0.3.0':
dependencies:
is-node-process: 1.2.0
@@ -4476,977 +4537,956 @@ snapshots:
'@open-draft/until@2.1.0': {}
+ '@oxc-project/types@0.127.0': {}
+
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
- '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
aria-hidden: 1.2.6
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
aria-hidden: 1.2.6
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
aria-hidden: 1.2.6
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/rect': 1.1.1
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
aria-hidden: 1.2.6
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
- use-sync-external-store: 1.6.0(react@19.2.4)
+ react: 19.2.5
+ use-sync-external-store: 1.6.0(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
'@radix-ui/rect': 1.1.1
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/rect@1.1.1': {}
- '@rolldown/pluginutils@1.0.0-rc.3': {}
-
- '@rollup/rollup-android-arm-eabi@4.59.0':
+ '@rolldown/binding-android-arm64@1.0.0-rc.17':
optional: true
- '@rollup/rollup-android-arm64@4.59.0':
+ '@rolldown/binding-darwin-arm64@1.0.0-rc.17':
optional: true
- '@rollup/rollup-darwin-arm64@4.59.0':
+ '@rolldown/binding-darwin-x64@1.0.0-rc.17':
optional: true
- '@rollup/rollup-darwin-x64@4.59.0':
+ '@rolldown/binding-freebsd-x64@1.0.0-rc.17':
optional: true
- '@rollup/rollup-freebsd-arm64@4.59.0':
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':
optional: true
- '@rollup/rollup-freebsd-x64@4.59.0':
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-arm-gnueabihf@4.59.0':
+ '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-arm-musleabihf@4.59.0':
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-arm64-gnu@4.59.0':
+ '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-arm64-musl@4.59.0':
+ '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-loong64-gnu@4.59.0':
+ '@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-loong64-musl@4.59.0':
+ '@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-ppc64-gnu@4.59.0':
+ '@rolldown/binding-wasm32-wasi@1.0.0-rc.17':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
- '@rollup/rollup-linux-ppc64-musl@4.59.0':
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-riscv64-gnu@4.59.0':
+ '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-riscv64-musl@4.59.0':
- optional: true
+ '@rolldown/pluginutils@1.0.0-rc.17': {}
- '@rollup/rollup-linux-s390x-gnu@4.59.0':
- optional: true
-
- '@rollup/rollup-linux-x64-gnu@4.59.0':
- optional: true
-
- '@rollup/rollup-linux-x64-musl@4.59.0':
- optional: true
-
- '@rollup/rollup-openbsd-x64@4.59.0':
- optional: true
-
- '@rollup/rollup-openharmony-arm64@4.59.0':
- optional: true
-
- '@rollup/rollup-win32-arm64-msvc@4.59.0':
- optional: true
-
- '@rollup/rollup-win32-ia32-msvc@4.59.0':
- optional: true
-
- '@rollup/rollup-win32-x64-gnu@4.59.0':
- optional: true
-
- '@rollup/rollup-win32-x64-msvc@4.59.0':
- optional: true
+ '@rolldown/pluginutils@1.0.0-rc.7': {}
'@sec-ant/readable-stream@0.4.1': {}
'@sindresorhus/merge-streams@4.0.0': {}
- '@tabler/icons-react@3.40.0(react@19.2.4)':
+ '@tabler/icons-react@3.41.1(react@19.2.5)':
dependencies:
- '@tabler/icons': 3.40.0
- react: 19.2.4
+ '@tabler/icons': 3.41.1
+ react: 19.2.5
- '@tabler/icons@3.40.0': {}
+ '@tabler/icons@3.41.1': {}
- '@tailwindcss/node@4.2.2':
+ '@tailwindcss/node@4.2.4':
dependencies:
'@jridgewell/remapping': 2.3.5
- enhanced-resolve: 5.20.1
- jiti: 2.6.1
+ enhanced-resolve: 5.21.0
+ jiti: 2.7.0
lightningcss: 1.32.0
magic-string: 0.30.21
source-map-js: 1.2.1
- tailwindcss: 4.2.2
+ tailwindcss: 4.2.4
- '@tailwindcss/oxide-android-arm64@4.2.2':
+ '@tailwindcss/oxide-android-arm64@4.2.4':
optional: true
- '@tailwindcss/oxide-darwin-arm64@4.2.2':
+ '@tailwindcss/oxide-darwin-arm64@4.2.4':
optional: true
- '@tailwindcss/oxide-darwin-x64@4.2.2':
+ '@tailwindcss/oxide-darwin-x64@4.2.4':
optional: true
- '@tailwindcss/oxide-freebsd-x64@4.2.2':
+ '@tailwindcss/oxide-freebsd-x64@4.2.4':
optional: true
- '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4':
optional: true
- '@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.4':
optional: true
- '@tailwindcss/oxide-linux-arm64-musl@4.2.2':
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.4':
optional: true
- '@tailwindcss/oxide-linux-x64-gnu@4.2.2':
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.4':
optional: true
- '@tailwindcss/oxide-linux-x64-musl@4.2.2':
+ '@tailwindcss/oxide-linux-x64-musl@4.2.4':
optional: true
- '@tailwindcss/oxide-wasm32-wasi@4.2.2':
+ '@tailwindcss/oxide-wasm32-wasi@4.2.4':
optional: true
- '@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.4':
optional: true
- '@tailwindcss/oxide-win32-x64-msvc@4.2.2':
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.4':
optional: true
- '@tailwindcss/oxide@4.2.2':
+ '@tailwindcss/oxide@4.2.4':
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/oxide-android-arm64': 4.2.4
+ '@tailwindcss/oxide-darwin-arm64': 4.2.4
+ '@tailwindcss/oxide-darwin-x64': 4.2.4
+ '@tailwindcss/oxide-freebsd-x64': 4.2.4
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4
+ '@tailwindcss/oxide-linux-arm64-musl': 4.2.4
+ '@tailwindcss/oxide-linux-x64-gnu': 4.2.4
+ '@tailwindcss/oxide-linux-x64-musl': 4.2.4
+ '@tailwindcss/oxide-wasm32-wasi': 4.2.4
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4
+ '@tailwindcss/oxide-win32-x64-msvc': 4.2.4
- '@tailwindcss/typography@0.5.19(tailwindcss@4.2.2)':
+ '@tailwindcss/typography@0.5.19(tailwindcss@4.2.4)':
dependencies:
postcss-selector-parser: 6.0.10
- tailwindcss: 4.2.2
+ tailwindcss: 4.2.4
- '@tailwindcss/vite@4.2.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))':
+ '@tailwindcss/vite@4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))':
dependencies:
- '@tailwindcss/node': 4.2.2
- '@tailwindcss/oxide': 4.2.2
- tailwindcss: 4.2.2
- vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
+ '@tailwindcss/node': 4.2.4
+ '@tailwindcss/oxide': 4.2.4
+ tailwindcss: 4.2.4
+ vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)
'@tanstack/history@1.161.6': {}
- '@tanstack/query-core@5.91.2': {}
+ '@tanstack/query-core@5.99.0': {}
- '@tanstack/react-query@5.91.2(react@19.2.4)':
+ '@tanstack/react-query@5.99.0(react@19.2.5)':
dependencies:
- '@tanstack/query-core': 5.91.2
- react: 19.2.4
+ '@tanstack/query-core': 5.99.0
+ react: 19.2.5
- '@tanstack/react-router-devtools@1.166.9(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.5)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@tanstack/react-router-devtools@1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@tanstack/react-router': 1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@tanstack/router-devtools-core': 1.166.9(@tanstack/router-core@1.167.5)(csstype@3.2.3)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@tanstack/react-router': 1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@tanstack/router-devtools-core': 1.167.3(@tanstack/router-core@1.169.2)(csstype@3.2.3)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
- '@tanstack/router-core': 1.167.5
+ '@tanstack/router-core': 1.169.2
transitivePeerDependencies:
- csstype
- '@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@tanstack/history': 1.161.6
- '@tanstack/react-store': 0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@tanstack/router-core': 1.167.5
- isbot: 5.1.36
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- tiny-invariant: 1.3.3
- tiny-warning: 1.0.3
+ '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@tanstack/router-core': 1.169.2
+ isbot: 5.1.40
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
- '@tanstack/react-store@0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@tanstack/store': 0.9.2
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- use-sync-external-store: 1.6.0(react@19.2.4)
+ '@tanstack/store': 0.9.3
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ use-sync-external-store: 1.6.0(react@19.2.5)
- '@tanstack/router-core@1.167.5':
+ '@tanstack/router-core@1.168.7':
dependencies:
'@tanstack/history': 1.161.6
- '@tanstack/store': 0.9.2
cookie-es: 2.0.0
seroval: 1.5.1
seroval-plugins: 1.5.1(seroval@1.5.1)
- tiny-invariant: 1.3.3
- tiny-warning: 1.0.3
- '@tanstack/router-devtools-core@1.166.9(@tanstack/router-core@1.167.5)(csstype@3.2.3)':
+ '@tanstack/router-core@1.169.2':
dependencies:
- '@tanstack/router-core': 1.167.5
+ '@tanstack/history': 1.161.6
+ cookie-es: 3.1.1
+ seroval: 1.5.4
+ seroval-plugins: 1.5.4(seroval@1.5.4)
+
+ '@tanstack/router-devtools-core@1.167.3(@tanstack/router-core@1.169.2)(csstype@3.2.3)':
+ dependencies:
+ '@tanstack/router-core': 1.169.2
clsx: 2.1.1
goober: 2.1.18(csstype@3.2.3)
- tiny-invariant: 1.3.3
optionalDependencies:
csstype: 3.2.3
- '@tanstack/router-generator@1.166.13':
+ '@tanstack/router-generator@1.166.22':
dependencies:
- '@tanstack/router-core': 1.167.5
+ '@tanstack/router-core': 1.168.7
'@tanstack/router-utils': 1.161.6
'@tanstack/virtual-file-routes': 1.161.7
- prettier: 3.8.1
+ prettier: 3.8.3
recast: 0.23.11
source-map: 0.7.6
tsx: 4.21.0
@@ -5454,7 +5494,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@tanstack/router-plugin@1.166.14(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))':
+ '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
@@ -5462,16 +5502,16 @@ snapshots:
'@babel/template': 7.28.6
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
- '@tanstack/router-core': 1.167.5
- '@tanstack/router-generator': 1.166.13
+ '@tanstack/router-core': 1.168.7
+ '@tanstack/router-generator': 1.166.22
'@tanstack/router-utils': 1.161.6
'@tanstack/virtual-file-routes': 1.161.7
chokidar: 3.6.0
unplugin: 2.3.11
zod: 3.25.76
optionalDependencies:
- '@tanstack/react-router': 1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
+ '@tanstack/react-router': 1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)
transitivePeerDependencies:
- supports-color
@@ -5483,17 +5523,17 @@ snapshots:
'@babel/types': 7.29.0
ansis: 4.2.0
babel-dead-code-elimination: 1.0.12
- diff: 8.0.3
+ diff: 8.0.4
pathe: 2.0.3
- tinyglobby: 0.2.15
+ tinyglobby: 0.2.16
transitivePeerDependencies:
- supports-color
- '@tanstack/store@0.9.2': {}
+ '@tanstack/store@0.9.3': {}
'@tanstack/virtual-file-routes@1.161.7': {}
- '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1)':
+ '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3)':
dependencies:
'@babel/generator': 7.29.1
'@babel/parser': 7.29.2
@@ -5503,41 +5543,27 @@ snapshots:
lodash-es: 4.17.23
minimatch: 9.0.9
parse-imports-exports: 0.2.4
- prettier: 3.8.1
+ prettier: 3.8.3
transitivePeerDependencies:
- supports-color
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3
- minimatch: 10.2.4
+ minimatch: 10.2.5
path-browserify: 1.0.1
- '@types/babel__core@7.20.5':
+ '@tybys/wasm-util@0.10.1':
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
+ tslib: 2.8.1
+ optional: true
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
+ '@types/esrecurse@4.3.1': {}
+
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
@@ -5556,9 +5582,9 @@ snapshots:
'@types/ms@2.1.0': {}
- '@types/node@25.5.0':
+ '@types/node@25.6.0':
dependencies:
- undici-types: 7.18.2
+ undici-types: 7.19.2
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
@@ -5568,6 +5594,10 @@ snapshots:
dependencies:
csstype: 3.2.3
+ '@types/set-cookie-parser@2.4.10':
+ dependencies:
+ '@types/node': 25.6.0
+
'@types/statuses@2.0.6': {}
'@types/unist@2.0.11': {}
@@ -5576,15 +5606,15 @@ snapshots:
'@types/validate-npm-package-name@4.0.2': {}
- '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/scope-manager': 8.57.1
- '@typescript-eslint/type-utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.57.1
- eslint: 9.39.4(jiti@2.6.1)
+ '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.58.2
+ '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.58.2
+ eslint: 10.2.1(jiti@2.7.0)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -5592,94 +5622,166 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/scope-manager': 8.57.1
- '@typescript-eslint/types': 8.57.1
- '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.57.1
- debug: 4.4.3
- eslint: 9.39.4(jiti@2.6.1)
- typescript: 5.9.3
- transitivePeerDependencies:
- - supports-color
-
- '@typescript-eslint/project-service@8.57.1(typescript@5.9.3)':
- dependencies:
- '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3)
- '@typescript-eslint/types': 8.57.1
- debug: 4.4.3
- typescript: 5.9.3
- transitivePeerDependencies:
- - supports-color
-
- '@typescript-eslint/scope-manager@8.57.1':
- dependencies:
- '@typescript-eslint/types': 8.57.1
- '@typescript-eslint/visitor-keys': 8.57.1
-
- '@typescript-eslint/tsconfig-utils@8.57.1(typescript@5.9.3)':
- dependencies:
- typescript: 5.9.3
-
- '@typescript-eslint/type-utils@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
- dependencies:
- '@typescript-eslint/types': 8.57.1
- '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3)
- '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- debug: 4.4.3
- eslint: 9.39.4(jiti@2.6.1)
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.59.1
+ '@typescript-eslint/type-utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.59.1
+ eslint: 10.2.1(jiti@2.7.0)
+ ignore: 7.0.5
+ natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/types@8.57.1': {}
-
- '@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3)':
+ '@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/project-service': 8.57.1(typescript@5.9.3)
- '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3)
- '@typescript-eslint/types': 8.57.1
- '@typescript-eslint/visitor-keys': 8.57.1
+ '@typescript-eslint/scope-manager': 8.59.1
+ '@typescript-eslint/types': 8.59.1
+ '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.59.1
debug: 4.4.3
- minimatch: 10.2.4
+ eslint: 10.2.1(jiti@2.7.0)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.58.2(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.59.1
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.59.1(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.59.1
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/scope-manager@8.58.2':
+ dependencies:
+ '@typescript-eslint/types': 8.58.2
+ '@typescript-eslint/visitor-keys': 8.58.2
+
+ '@typescript-eslint/scope-manager@8.59.1':
+ dependencies:
+ '@typescript-eslint/types': 8.59.1
+ '@typescript-eslint/visitor-keys': 8.59.1
+
+ '@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@typescript-eslint/tsconfig-utils@8.59.1(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@typescript-eslint/type-utils@8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.58.2
+ '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 10.2.1(jiti@2.7.0)
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/type-utils@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.59.1
+ '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 10.2.1(jiti@2.7.0)
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/types@8.58.2': {}
+
+ '@typescript-eslint/types@8.59.1': {}
+
+ '@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.58.2(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3)
+ '@typescript-eslint/types': 8.58.2
+ '@typescript-eslint/visitor-keys': 8.58.2
+ debug: 4.4.3
+ minimatch: 10.2.5
semver: 7.7.4
- tinyglobby: 0.2.15
+ tinyglobby: 0.2.16
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/typescript-estree@8.59.1(typescript@5.9.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
- '@typescript-eslint/scope-manager': 8.57.1
- '@typescript-eslint/types': 8.57.1
- '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3)
- eslint: 9.39.4(jiti@2.6.1)
+ '@typescript-eslint/project-service': 8.59.1(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.59.1
+ '@typescript-eslint/visitor-keys': 8.59.1
+ debug: 4.4.3
+ minimatch: 10.2.5
+ semver: 7.7.4
+ tinyglobby: 0.2.16
+ ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/visitor-keys@8.57.1':
+ '@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/types': 8.57.1
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0))
+ '@typescript-eslint/scope-manager': 8.58.2
+ '@typescript-eslint/types': 8.58.2
+ '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3)
+ eslint: 10.2.1(jiti@2.7.0)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/utils@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0))
+ '@typescript-eslint/scope-manager': 8.59.1
+ '@typescript-eslint/types': 8.59.1
+ '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3)
+ eslint: 10.2.1(jiti@2.7.0)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/visitor-keys@8.58.2':
+ dependencies:
+ '@typescript-eslint/types': 8.58.2
+ eslint-visitor-keys: 5.0.1
+
+ '@typescript-eslint/visitor-keys@8.59.1':
+ dependencies:
+ '@typescript-eslint/types': 8.59.1
eslint-visitor-keys: 5.0.1
'@ungap/structured-clone@1.3.0': {}
- '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))':
+ '@vitejs/plugin-react@6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.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-rc.3
- '@types/babel__core': 7.20.5
- react-refresh: 0.18.0
- vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
- transitivePeerDependencies:
- - supports-color
+ '@rolldown/pluginutils': 1.0.0-rc.7
+ vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)
accepts@2.0.0:
dependencies:
@@ -5727,7 +5829,7 @@ snapshots:
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
- picomatch: 2.3.1
+ picomatch: 2.3.2
argparse@2.0.1: {}
@@ -5754,7 +5856,7 @@ snapshots:
balanced-match@4.0.4: {}
- baseline-browser-mapping@2.10.9: {}
+ baseline-browser-mapping@2.10.17: {}
binary-extensions@2.3.0: {}
@@ -5766,22 +5868,17 @@ snapshots:
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
- qs: 6.15.0
+ qs: 6.15.1
raw-body: 3.0.2
type-is: 2.0.1
transitivePeerDependencies:
- supports-color
- brace-expansion@1.1.12:
- dependencies:
- balanced-match: 1.0.2
- concat-map: 0.0.1
-
- brace-expansion@2.0.2:
+ brace-expansion@2.0.3:
dependencies:
balanced-match: 1.0.2
- brace-expansion@5.0.4:
+ brace-expansion@5.0.5:
dependencies:
balanced-match: 4.0.4
@@ -5789,13 +5886,13 @@ snapshots:
dependencies:
fill-range: 7.1.1
- browserslist@4.28.1:
+ browserslist@4.28.2:
dependencies:
- baseline-browser-mapping: 2.10.9
- caniuse-lite: 1.0.30001780
- electron-to-chromium: 1.5.321
- node-releases: 2.0.36
- update-browserslist-db: 1.2.3(browserslist@4.28.1)
+ baseline-browser-mapping: 2.10.17
+ caniuse-lite: 1.0.30001787
+ electron-to-chromium: 1.5.334
+ node-releases: 2.0.37
+ update-browserslist-db: 1.2.3(browserslist@4.28.2)
bundle-name@4.1.0:
dependencies:
@@ -5815,15 +5912,10 @@ snapshots:
callsites@3.1.0: {}
- caniuse-lite@1.0.30001780: {}
+ caniuse-lite@1.0.30001787: {}
ccount@2.0.1: {}
- chalk@4.1.2:
- dependencies:
- ansi-styles: 4.3.0
- supports-color: 7.2.0
-
chalk@5.6.2: {}
character-entities-html4@2.1.0: {}
@@ -5880,9 +5972,7 @@ snapshots:
commander@14.0.3: {}
- concat-map@0.0.1: {}
-
- content-disposition@1.0.1: {}
+ content-disposition@1.1.0: {}
content-type@1.0.5: {}
@@ -5890,6 +5980,8 @@ snapshots:
cookie-es@2.0.0: {}
+ cookie-es@3.1.1: {}
+
cookie-signature@1.2.2: {}
cookie@0.7.2: {}
@@ -5959,9 +6051,9 @@ snapshots:
dependencies:
dequal: 2.0.3
- diff@8.0.3: {}
+ diff@8.0.4: {}
- dotenv@17.3.1: {}
+ dotenv@17.4.2: {}
dunder-proto@1.0.1:
dependencies:
@@ -5971,14 +6063,14 @@ snapshots:
eciesjs@0.4.18:
dependencies:
- '@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0)
+ '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0)
'@noble/ciphers': 1.3.0
'@noble/curves': 1.9.7
'@noble/hashes': 1.8.0
ee-first@1.1.1: {}
- electron-to-chromium@1.5.321: {}
+ electron-to-chromium@1.5.334: {}
emoji-regex@10.6.0: {}
@@ -5986,10 +6078,10 @@ snapshots:
encodeurl@2.0.0: {}
- enhanced-resolve@5.20.1:
+ enhanced-resolve@5.21.0:
dependencies:
graceful-fs: 4.2.11
- tapable: 2.3.0
+ tapable: 2.3.3
entities@6.0.1: {}
@@ -6044,58 +6136,55 @@ snapshots:
escape-string-regexp@5.0.0: {}
- eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)):
+ eslint-config-prettier@10.1.8(eslint@10.2.1(jiti@2.7.0)):
dependencies:
- eslint: 9.39.4(jiti@2.6.1)
+ eslint: 10.2.1(jiti@2.7.0)
- eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)):
+ eslint-plugin-react-hooks@7.1.1(eslint@10.2.1(jiti@2.7.0)):
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.2
- eslint: 9.39.4(jiti@2.6.1)
+ eslint: 10.2.1(jiti@2.7.0)
hermes-parser: 0.25.1
zod: 4.3.6
zod-validation-error: 4.0.2(zod@4.3.6)
transitivePeerDependencies:
- supports-color
- eslint-plugin-react-refresh@0.4.26(eslint@9.39.4(jiti@2.6.1)):
+ eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.7.0)):
dependencies:
- eslint: 9.39.4(jiti@2.6.1)
+ eslint: 10.2.1(jiti@2.7.0)
- eslint-scope@8.4.0:
+ eslint-scope@9.1.2:
dependencies:
+ '@types/esrecurse': 4.3.1
+ '@types/estree': 1.0.8
esrecurse: 4.3.0
estraverse: 5.3.0
eslint-visitor-keys@3.4.3: {}
- eslint-visitor-keys@4.2.1: {}
-
eslint-visitor-keys@5.0.1: {}
- eslint@9.39.4(jiti@2.6.1):
+ eslint@10.2.1(jiti@2.7.0):
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0))
'@eslint-community/regexpp': 4.12.2
- '@eslint/config-array': 0.21.2
- '@eslint/config-helpers': 0.4.2
- '@eslint/core': 0.17.0
- '@eslint/eslintrc': 3.3.5
- '@eslint/js': 9.39.4
- '@eslint/plugin-kit': 0.4.1
+ '@eslint/config-array': 0.23.5
+ '@eslint/config-helpers': 0.5.5
+ '@eslint/core': 1.2.1
+ '@eslint/plugin-kit': 0.7.1
'@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
ajv: 6.14.0
- chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
escape-string-regexp: 4.0.0
- eslint-scope: 8.4.0
- eslint-visitor-keys: 4.2.1
- espree: 10.4.0
+ eslint-scope: 9.1.2
+ eslint-visitor-keys: 5.0.1
+ espree: 11.2.0
esquery: 1.7.0
esutils: 2.0.3
fast-deep-equal: 3.1.3
@@ -6106,20 +6195,19 @@ snapshots:
imurmurhash: 0.1.4
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
- lodash.merge: 4.6.2
- minimatch: 3.1.5
+ minimatch: 10.2.5
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
- jiti: 2.6.1
+ jiti: 2.7.0
transitivePeerDependencies:
- supports-color
- espree@10.4.0:
+ espree@11.2.0:
dependencies:
acorn: 8.16.0
acorn-jsx: 5.3.2(acorn@8.16.0)
- eslint-visitor-keys: 4.2.1
+ eslint-visitor-keys: 5.0.1
esprima@4.0.1: {}
@@ -6172,7 +6260,7 @@ snapshots:
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
- express-rate-limit@8.3.1(express@5.2.1):
+ express-rate-limit@8.3.2(express@5.2.1):
dependencies:
express: 5.2.1
ip-address: 10.1.0
@@ -6181,7 +6269,7 @@ snapshots:
dependencies:
accepts: 2.0.0
body-parser: 2.2.2
- content-disposition: 1.0.1
+ content-disposition: 1.1.0
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
@@ -6199,7 +6287,7 @@ snapshots:
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
- qs: 6.15.0
+ qs: 6.15.1
range-parser: 1.2.1
router: 2.2.0
send: 1.2.1
@@ -6226,15 +6314,25 @@ snapshots:
fast-levenshtein@2.0.6: {}
+ fast-string-truncated-width@3.0.3: {}
+
+ fast-string-width@3.0.2:
+ dependencies:
+ fast-string-truncated-width: 3.0.3
+
fast-uri@3.1.0: {}
+ fast-wrap-ansi@0.2.0:
+ dependencies:
+ fast-string-width: 3.0.2
+
fastq@1.20.1:
dependencies:
reusify: 1.1.0
- fdir@6.5.0(picomatch@4.0.3):
+ fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
- picomatch: 4.0.3
+ picomatch: 4.0.4
fetch-blob@3.2.0:
dependencies:
@@ -6332,7 +6430,7 @@ snapshots:
'@sec-ant/readable-stream': 0.4.1
is-stream: 4.0.1
- get-tsconfig@4.13.6:
+ get-tsconfig@4.13.7:
dependencies:
resolve-pkg-maps: 1.0.0
@@ -6344,9 +6442,7 @@ snapshots:
dependencies:
is-glob: 4.0.3
- globals@14.0.0: {}
-
- globals@16.5.0: {}
+ globals@17.5.0: {}
goober@2.1.18(csstype@3.2.3):
dependencies:
@@ -6356,9 +6452,7 @@ snapshots:
graceful-fs@4.2.11: {}
- graphql@16.13.1: {}
-
- has-flag@4.0.0: {}
+ graphql@16.13.2: {}
has-symbols@1.1.0: {}
@@ -6377,6 +6471,10 @@ snapshots:
vfile-location: 5.0.3
web-namespaces: 2.0.1
+ hast-util-is-element@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
hast-util-parse-selector@4.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -6433,6 +6531,13 @@ snapshots:
web-namespaces: 2.0.1
zwitch: 2.0.4
+ hast-util-to-text@4.0.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ hast-util-is-element: 3.0.0
+ unist-util-find-after: 5.0.0
+
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -6445,7 +6550,10 @@ snapshots:
property-information: 7.1.0
space-separated-tokens: 2.0.2
- headers-polyfill@4.0.3: {}
+ headers-polyfill@5.0.1:
+ dependencies:
+ '@types/set-cookie-parser': 2.4.10
+ set-cookie-parser: 3.1.0
hermes-estree@0.25.1: {}
@@ -6453,7 +6561,9 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
- hono@4.12.8: {}
+ highlight.js@11.11.1: {}
+
+ hono@4.12.14: {}
html-parse-stringify@3.0.1:
dependencies:
@@ -6486,9 +6596,7 @@ snapshots:
dependencies:
'@babel/runtime': 7.29.2
- i18next@25.8.20(typescript@5.9.3):
- dependencies:
- '@babel/runtime': 7.29.2
+ i18next@26.0.8(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
@@ -6574,7 +6682,7 @@ snapshots:
dependencies:
is-inside-container: 1.0.0
- isbot@5.1.36: {}
+ isbot@5.1.40: {}
isexe@2.0.0: {}
@@ -6582,16 +6690,16 @@ snapshots:
javascript-natural-sort@0.7.1: {}
- jiti@2.6.1: {}
+ jiti@2.7.0: {}
jose@6.2.2: {}
- jotai@2.18.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4):
+ jotai@2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5):
optionalDependencies:
'@babel/core': 7.29.0
'@babel/template': 7.28.6
'@types/react': 19.2.14
- react: 19.2.4
+ react: 19.2.5
js-tokens@4.0.0: {}
@@ -6691,8 +6799,6 @@ snapshots:
lodash-es@4.17.23: {}
- lodash.merge@4.6.2: {}
-
log-symbols@6.0.0:
dependencies:
chalk: 5.6.2
@@ -6700,6 +6806,12 @@ snapshots:
longest-streak@3.1.0: {}
+ lowlight@3.3.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ devlop: 1.1.0
+ highlight.js: 11.11.1
+
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -7067,7 +7179,7 @@ snapshots:
micromatch@4.0.8:
dependencies:
braces: 3.0.3
- picomatch: 2.3.1
+ picomatch: 2.3.2
mime-db@1.54.0: {}
@@ -7079,36 +7191,32 @@ snapshots:
mimic-function@5.0.1: {}
- minimatch@10.2.4:
+ minimatch@10.2.5:
dependencies:
- brace-expansion: 5.0.4
-
- minimatch@3.1.5:
- dependencies:
- brace-expansion: 1.1.12
+ brace-expansion: 5.0.5
minimatch@9.0.9:
dependencies:
- brace-expansion: 2.0.2
+ brace-expansion: 2.0.3
minimist@1.2.8: {}
ms@2.1.3: {}
- msw@2.12.13(@types/node@25.5.0)(typescript@5.9.3):
+ msw@2.13.4(@types/node@25.6.0)(typescript@5.9.3):
dependencies:
- '@inquirer/confirm': 5.1.21(@types/node@25.5.0)
+ '@inquirer/confirm': 6.0.11(@types/node@25.6.0)
'@mswjs/interceptors': 0.41.3
- '@open-draft/deferred-promise': 2.2.0
+ '@open-draft/deferred-promise': 3.0.0
'@types/statuses': 2.0.6
cookie: 1.1.1
- graphql: 16.13.1
- headers-polyfill: 4.0.3
+ graphql: 16.13.2
+ headers-polyfill: 5.0.1
is-node-process: 1.2.0
outvariant: 1.4.3
path-to-regexp: 6.3.0
picocolors: 1.1.1
- rettime: 0.10.1
+ rettime: 0.11.7
statuses: 2.0.2
strict-event-emitter: 0.5.1
tough-cookie: 6.0.1
@@ -7120,7 +7228,7 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
- mute-stream@2.0.0: {}
+ mute-stream@3.0.0: {}
nanoid@3.3.11: {}
@@ -7136,7 +7244,7 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
- node-releases@2.0.36: {}
+ node-releases@2.0.37: {}
normalize-path@3.0.0: {}
@@ -7256,15 +7364,15 @@ snapshots:
path-to-regexp@6.3.0: {}
- path-to-regexp@8.3.0: {}
+ path-to-regexp@8.4.2: {}
pathe@2.0.3: {}
picocolors@1.1.1: {}
- picomatch@2.3.1: {}
+ picomatch@2.3.2: {}
- picomatch@4.0.3: {}
+ picomatch@4.0.4: {}
pkce-challenge@5.0.1: {}
@@ -7278,7 +7386,7 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
- postcss@8.5.8:
+ postcss@8.5.10:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
@@ -7288,13 +7396,13 @@ snapshots:
prelude-ls@1.2.1: {}
- prettier-plugin-tailwindcss@0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1))(prettier@3.8.1):
+ prettier-plugin-tailwindcss@0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3))(prettier@3.8.3):
dependencies:
- prettier: 3.8.1
+ prettier: 3.8.3
optionalDependencies:
- '@trivago/prettier-plugin-sort-imports': 6.0.2(prettier@3.8.1)
+ '@trivago/prettier-plugin-sort-imports': 6.0.2(prettier@3.8.3)
- prettier@3.8.1: {}
+ prettier@3.8.3: {}
pretty-ms@9.3.0:
dependencies:
@@ -7314,71 +7422,71 @@ snapshots:
punycode@2.3.1: {}
- qs@6.15.0:
+ qs@6.15.1:
dependencies:
side-channel: 1.1.0
queue-microtask@1.2.3: {}
- radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
@@ -7392,23 +7500,23 @@ snapshots:
iconv-lite: 0.7.2
unpipe: 1.0.0
- react-dom@19.2.4(react@19.2.4):
+ react-dom@19.2.5(react@19.2.5):
dependencies:
- react: 19.2.4
+ react: 19.2.5
scheduler: 0.27.0
- react-i18next@16.5.8(i18next@25.8.20(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
+ react-i18next@17.0.4(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.29.2
html-parse-stringify: 3.0.1
- i18next: 25.8.20(typescript@5.9.3)
- react: 19.2.4
- use-sync-external-store: 1.6.0(react@19.2.4)
+ i18next: 26.0.8(typescript@5.9.3)
+ react: 19.2.5
+ use-sync-external-store: 1.6.0(react@19.2.5)
optionalDependencies:
- react-dom: 19.2.4(react@19.2.4)
+ react-dom: 19.2.5(react@19.2.5)
typescript: 5.9.3
- react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4):
+ react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5):
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
@@ -7417,7 +7525,7 @@ snapshots:
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
+ react: 19.2.5
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
@@ -7426,49 +7534,47 @@ snapshots:
transitivePeerDependencies:
- supports-color
- react-refresh@0.18.0: {}
-
- react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4):
+ react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
- react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5)
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
- react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4):
+ react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
- react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4)
- react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.5)
+ react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5)
tslib: 2.8.1
- use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4)
- use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4)
+ use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.5)
+ use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
- react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4):
+ react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5):
dependencies:
get-nonce: 1.0.1
- react: 19.2.4
+ react: 19.2.5
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
- react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.4):
+ react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.5):
dependencies:
'@babel/runtime': 7.29.2
- react: 19.2.4
- use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.4)
- use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.5)
+ use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.5)
transitivePeerDependencies:
- '@types/react'
- react@19.2.4: {}
+ react@19.2.5: {}
readdirp@3.6.0:
dependencies:
- picomatch: 2.3.1
+ picomatch: 2.3.2
recast@0.23.11:
dependencies:
@@ -7478,6 +7584,14 @@ snapshots:
tiny-invariant: 1.3.3
tslib: 2.8.1
+ rehype-highlight@7.0.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-to-text: 4.0.2
+ lowlight: 3.3.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+
rehype-raw@7.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -7536,40 +7650,30 @@ snapshots:
onetime: 7.0.0
signal-exit: 4.1.0
- rettime@0.10.1: {}
+ rettime@0.11.7: {}
reusify@1.1.0: {}
- rollup@4.59.0:
+ rolldown@1.0.0-rc.17:
dependencies:
- '@types/estree': 1.0.8
+ '@oxc-project/types': 0.127.0
+ '@rolldown/pluginutils': 1.0.0-rc.17
optionalDependencies:
- '@rollup/rollup-android-arm-eabi': 4.59.0
- '@rollup/rollup-android-arm64': 4.59.0
- '@rollup/rollup-darwin-arm64': 4.59.0
- '@rollup/rollup-darwin-x64': 4.59.0
- '@rollup/rollup-freebsd-arm64': 4.59.0
- '@rollup/rollup-freebsd-x64': 4.59.0
- '@rollup/rollup-linux-arm-gnueabihf': 4.59.0
- '@rollup/rollup-linux-arm-musleabihf': 4.59.0
- '@rollup/rollup-linux-arm64-gnu': 4.59.0
- '@rollup/rollup-linux-arm64-musl': 4.59.0
- '@rollup/rollup-linux-loong64-gnu': 4.59.0
- '@rollup/rollup-linux-loong64-musl': 4.59.0
- '@rollup/rollup-linux-ppc64-gnu': 4.59.0
- '@rollup/rollup-linux-ppc64-musl': 4.59.0
- '@rollup/rollup-linux-riscv64-gnu': 4.59.0
- '@rollup/rollup-linux-riscv64-musl': 4.59.0
- '@rollup/rollup-linux-s390x-gnu': 4.59.0
- '@rollup/rollup-linux-x64-gnu': 4.59.0
- '@rollup/rollup-linux-x64-musl': 4.59.0
- '@rollup/rollup-openbsd-x64': 4.59.0
- '@rollup/rollup-openharmony-arm64': 4.59.0
- '@rollup/rollup-win32-arm64-msvc': 4.59.0
- '@rollup/rollup-win32-ia32-msvc': 4.59.0
- '@rollup/rollup-win32-x64-gnu': 4.59.0
- '@rollup/rollup-win32-x64-msvc': 4.59.0
- fsevents: 2.3.3
+ '@rolldown/binding-android-arm64': 1.0.0-rc.17
+ '@rolldown/binding-darwin-arm64': 1.0.0-rc.17
+ '@rolldown/binding-darwin-x64': 1.0.0-rc.17
+ '@rolldown/binding-freebsd-x64': 1.0.0-rc.17
+ '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17
+ '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17
+ '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17
+ '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17
+ '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17
+ '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17
+ '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17
+ '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17
+ '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17
+ '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17
+ '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17
router@2.2.0:
dependencies:
@@ -7577,7 +7681,7 @@ snapshots:
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
- path-to-regexp: 8.3.0
+ path-to-regexp: 8.4.2
transitivePeerDependencies:
- supports-color
@@ -7615,8 +7719,14 @@ snapshots:
dependencies:
seroval: 1.5.1
+ seroval-plugins@1.5.4(seroval@1.5.4):
+ dependencies:
+ seroval: 1.5.4
+
seroval@1.5.1: {}
+ seroval@1.5.4: {}
+
serve-static@2.2.1:
dependencies:
encodeurl: 2.0.0
@@ -7626,34 +7736,36 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ set-cookie-parser@3.1.0: {}
+
setprototypeof@1.2.0: {}
- shadcn@4.1.0(@types/node@25.5.0)(typescript@5.9.3):
+ shadcn@4.3.0(@types/node@25.6.0)(typescript@5.9.3):
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.2
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.0)
- '@dotenvx/dotenvx': 1.57.0
- '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76)
+ '@dotenvx/dotenvx': 1.61.0
+ '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76)
'@types/validate-npm-package-name': 4.0.2
- browserslist: 4.28.1
+ browserslist: 4.28.2
commander: 14.0.3
cosmiconfig: 9.0.1(typescript@5.9.3)
dedent: 1.7.2
deepmerge: 4.3.1
- diff: 8.0.3
+ diff: 8.0.4
execa: 9.6.1
fast-glob: 3.3.3
fs-extra: 11.3.4
fuzzysort: 3.1.0
https-proxy-agent: 7.0.6
kleur: 4.1.5
- msw: 2.12.13(@types/node@25.5.0)(typescript@5.9.3)
+ msw: 2.13.4(@types/node@25.6.0)(typescript@5.9.3)
node-fetch: 3.3.2
open: 11.0.0
ora: 8.2.0
- postcss: 8.5.8
+ postcss: 8.5.10
postcss-selector-parser: 7.1.1
prompts: 2.4.2
recast: 0.23.11
@@ -7663,7 +7775,7 @@ snapshots:
tsconfig-paths: 4.2.0
validate-npm-package-name: 7.0.2
zod: 3.25.76
- zod-to-json-schema: 3.25.1(zod@3.25.76)
+ zod-to-json-schema: 3.25.2(zod@3.25.76)
transitivePeerDependencies:
- '@cfworker/json-schema'
- '@types/node'
@@ -7677,7 +7789,7 @@ snapshots:
shebang-regex@3.0.0: {}
- side-channel-list@1.0.0:
+ side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
@@ -7701,7 +7813,7 @@ snapshots:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
- side-channel-list: 1.0.0
+ side-channel-list: 1.0.1
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
@@ -7711,10 +7823,10 @@ snapshots:
sisteransi@1.0.5: {}
- sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
source-map-js@1.2.1: {}
@@ -7772,8 +7884,6 @@ snapshots:
strip-final-newline@4.0.0: {}
- strip-json-comments@3.1.1: {}
-
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
@@ -7782,32 +7892,26 @@ snapshots:
dependencies:
inline-style-parser: 0.2.7
- supports-color@7.2.0:
- dependencies:
- has-flag: 4.0.0
-
tagged-tag@1.0.0: {}
tailwind-merge@3.5.0: {}
- tailwindcss@4.2.2: {}
+ tailwindcss@4.2.4: {}
- tapable@2.3.0: {}
+ tapable@2.3.3: {}
tiny-invariant@1.3.3: {}
- tiny-warning@1.0.3: {}
-
- tinyglobby@0.2.15:
+ tinyglobby@0.2.16:
dependencies:
- fdir: 6.5.0(picomatch@4.0.3)
- picomatch: 4.0.3
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
- tldts-core@7.0.26: {}
+ tldts-core@7.0.28: {}
- tldts@7.0.26:
+ tldts@7.0.28:
dependencies:
- tldts-core: 7.0.26
+ tldts-core: 7.0.28
to-regex-range@5.0.1:
dependencies:
@@ -7817,7 +7921,7 @@ snapshots:
tough-cookie@6.0.1:
dependencies:
- tldts: 7.0.26
+ tldts: 7.0.28
trim-lines@3.0.1: {}
@@ -7843,7 +7947,7 @@ snapshots:
tsx@4.21.0:
dependencies:
esbuild: 0.27.4
- get-tsconfig: 4.13.6
+ get-tsconfig: 4.13.7
optionalDependencies:
fsevents: 2.3.3
@@ -7863,20 +7967,20 @@ snapshots:
media-typer: 1.1.0
mime-types: 3.0.2
- typescript-eslint@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
+ typescript-eslint@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/parser': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3)
- '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- eslint: 9.39.4(jiti@2.6.1)
+ '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ eslint: 10.2.1(jiti@2.7.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
typescript@5.9.3: {}
- undici-types@7.18.2: {}
+ undici-types@7.19.2: {}
unicorn-magic@0.3.0: {}
@@ -7890,6 +7994,11 @@ snapshots:
trough: 2.2.0
vfile: 6.0.3
+ unist-util-find-after@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
unist-util-is@6.0.1:
dependencies:
'@types/unist': 3.0.3
@@ -7921,14 +8030,14 @@ snapshots:
dependencies:
'@jridgewell/remapping': 2.3.5
acorn: 8.16.0
- picomatch: 4.0.3
+ picomatch: 4.0.4
webpack-virtual-modules: 0.6.2
until-async@3.0.2: {}
- update-browserslist-db@1.2.3(browserslist@4.28.1):
+ update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies:
- browserslist: 4.28.1
+ browserslist: 4.28.2
escalade: 3.2.0
picocolors: 1.1.1
@@ -7936,43 +8045,43 @@ snapshots:
dependencies:
punycode: 2.3.1
- use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4):
+ use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
+ react: 19.2.5
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
- use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.4):
+ use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.4):
+ use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- use-latest@1.3.0(@types/react@19.2.14)(react@19.2.4):
+ use-latest@1.3.0(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
- use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
- use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4):
+ use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.5):
dependencies:
detect-node-es: 1.1.0
- react: 19.2.4
+ react: 19.2.5
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
- use-sync-external-store@1.6.0(react@19.2.4):
+ use-sync-external-store@1.6.0(react@19.2.5):
dependencies:
- react: 19.2.4
+ react: 19.2.5
util-deprecate@1.0.2: {}
@@ -7995,19 +8104,18 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
- vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0):
+ vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0):
dependencies:
- esbuild: 0.27.4
- fdir: 6.5.0(picomatch@4.0.3)
- picomatch: 4.0.3
- postcss: 8.5.8
- rollup: 4.59.0
- tinyglobby: 0.2.15
- optionalDependencies:
- '@types/node': 25.5.0
- fsevents: 2.3.3
- jiti: 2.6.1
lightningcss: 1.32.0
+ picomatch: 4.0.4
+ postcss: 8.5.10
+ rolldown: 1.0.0-rc.17
+ tinyglobby: 0.2.16
+ optionalDependencies:
+ '@types/node': 25.6.0
+ esbuild: 0.27.4
+ fsevents: 2.3.3
+ jiti: 2.7.0
tsx: 4.21.0
void-elements@3.1.0: {}
@@ -8034,12 +8142,6 @@ snapshots:
string-width: 8.2.0
strip-ansi: 7.2.0
- wrap-ansi@6.2.0:
- dependencies:
- ansi-styles: 4.3.0
- string-width: 4.2.3
- strip-ansi: 6.0.1
-
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -8071,11 +8173,13 @@ snapshots:
yocto-queue@0.1.0: {}
- yoctocolors-cjs@2.1.3: {}
+ yocto-spinner@1.1.0:
+ dependencies:
+ yoctocolors: 2.1.2
yoctocolors@2.1.2: {}
- zod-to-json-schema@3.25.1(zod@3.25.76):
+ zod-to-json-schema@3.25.2(zod@3.25.76):
dependencies:
zod: 3.25.76
diff --git a/web/frontend/src/api/channels.ts b/web/frontend/src/api/channels.ts
index eb4d41fd7..42a3a0606 100644
--- a/web/frontend/src/api/channels.ts
+++ b/web/frontend/src/api/channels.ts
@@ -1,5 +1,3 @@
-// API client for channels navigation and channel-specific config flows.
-
import { launcherFetch } from "@/api/http"
export type ChannelConfig = Record
@@ -12,6 +10,13 @@ export interface SupportedChannel {
variant?: string
}
+export interface ChannelConfigResponse {
+ config: ChannelConfig
+ configured_secrets: string[]
+ config_key: string
+ variant?: string
+}
+
interface ChannelsCatalogResponse {
channels: SupportedChannel[]
}
@@ -54,6 +59,14 @@ export async function getAppConfig(): Promise {
return request("/api/config")
}
+export async function getChannelConfig(
+ channelName: string,
+): Promise {
+ return request(
+ `/api/channels/${encodeURIComponent(channelName)}/config`,
+ )
+}
+
export async function patchAppConfig(
patch: Record,
): Promise {
diff --git a/web/frontend/src/api/http.ts b/web/frontend/src/api/http.ts
index 0eb872f3f..347dd9373 100644
--- a/web/frontend/src/api/http.ts
+++ b/web/frontend/src/api/http.ts
@@ -1,14 +1,14 @@
-import { isLauncherLoginPathname } from "@/lib/launcher-login-path"
+import { isLauncherAuthPathname } from "@/lib/launcher-login-path"
-function isLauncherLoginPath(): boolean {
+function isLauncherAuthPath(): boolean {
if (typeof globalThis.location === "undefined") {
return false
}
- if (isLauncherLoginPathname(globalThis.location.pathname || "/")) {
+ if (isLauncherAuthPathname(globalThis.location.pathname || "/")) {
return true
}
try {
- return isLauncherLoginPathname(
+ return isLauncherAuthPathname(
new URL(globalThis.location.href).pathname || "/",
)
} catch {
@@ -18,7 +18,7 @@ function isLauncherLoginPath(): boolean {
/**
* Same-origin fetch that sends cookies; redirects to launcher login on 401 JSON responses.
- * Skips redirect while already on the login page to avoid reload loops (e.g. gateway poll).
+ * Skips redirect while already on an auth page (login or setup) to avoid reload loops.
*/
export async function launcherFetch(
input: RequestInfo | URL,
@@ -33,7 +33,7 @@ export async function launcherFetch(
if (
ct.includes("application/json") &&
typeof globalThis.location !== "undefined" &&
- !isLauncherLoginPath()
+ !isLauncherAuthPath()
) {
globalThis.location.assign("/launcher-login")
}
diff --git a/web/frontend/src/api/launcher-auth.ts b/web/frontend/src/api/launcher-auth.ts
index 247d5ab9e..c7318d962 100644
--- a/web/frontend/src/api/launcher-auth.ts
+++ b/web/frontend/src/api/launcher-auth.ts
@@ -1,29 +1,33 @@
/**
- * Dashboard launcher token login. Uses plain fetch (not launcherFetch) to avoid
- * redirect loops on 401 while on the login page.
+ * Dashboard launcher auth API.
+ * Uses plain fetch (not launcherFetch) to avoid redirect loops on auth pages.
*/
+export type LoginResult =
+ | { ok: true }
+ | { ok: false; status: number; error: string }
+
export async function postLauncherDashboardLogin(
- token: string,
-): Promise {
+ password: string,
+): Promise {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
- body: JSON.stringify({ token: token.trim() }),
+ body: JSON.stringify({ password: password.trim() }),
})
- return res.ok
-}
+ if (res.ok) return { ok: true }
-export type LauncherAuthTokenHelp = {
- env_var_name: string
- log_file?: string
- tray_copy_menu: boolean
- console_stdout: boolean
+ return {
+ ok: false,
+ status: res.status,
+ error: await readLauncherAuthError(res),
+ }
}
export type LauncherAuthStatus = {
authenticated: boolean
- token_help?: LauncherAuthTokenHelp
+ /** true when a bcrypt password has been stored in the DB */
+ initialized: boolean
}
export async function getLauncherAuthStatus(): Promise {
@@ -46,3 +50,33 @@ export async function postLauncherDashboardLogout(): Promise {
})
return res.ok
}
+
+export type SetupResult = { ok: true } | { ok: false; error: string }
+
+export async function postLauncherDashboardSetup(
+ password: string,
+ confirm: string,
+): Promise {
+ const res = await fetch("/api/auth/setup", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "same-origin",
+ body: JSON.stringify({
+ password: password.trim(),
+ confirm: confirm.trim(),
+ }),
+ })
+ if (res.ok) return { ok: true }
+ return { ok: false, error: await readLauncherAuthError(res) }
+}
+
+async function readLauncherAuthError(res: Response): Promise {
+ let msg = `Request failed with status ${res.status}`
+ try {
+ const j = (await res.json()) as { error?: string }
+ if (j.error) msg = j.error
+ } catch {
+ /* ignore */
+ }
+ return msg
+}
diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts
index d75b3ec3c..5bb275fde 100644
--- a/web/frontend/src/api/models.ts
+++ b/web/frontend/src/api/models.ts
@@ -6,6 +6,7 @@ import { refreshGatewayState } from "@/store/gateway"
export interface ModelInfo {
index: number
model_name: string
+ provider?: string
model: string
api_base?: string
api_key: string
@@ -18,17 +19,32 @@ export interface ModelInfo {
max_tokens_field?: string
request_timeout?: number
thinking_level?: string
+ tool_schema_transform?: string
extra_body?: Record
+ custom_headers?: Record
// Meta
- configured: boolean
+ available: boolean
+ status: "available" | "unconfigured" | "unreachable"
is_default: boolean
is_virtual: boolean
+ default_model_allowed?: boolean
+}
+
+export interface ModelProviderOption {
+ id: string
+ default_api_base: string
+ empty_api_key_allowed: boolean
+ create_allowed: boolean
+ default_model_allowed: boolean
+ default_auth_method?: string
+ auth_method_locked?: boolean
}
interface ModelsListResponse {
models: ModelInfo[]
total: number
default_model: string
+ provider_options: ModelProviderOption[]
}
interface ModelActionResponse {
diff --git a/web/frontend/src/api/pico.ts b/web/frontend/src/api/pico.ts
index 6b8ceb49a..ca98a06da 100644
--- a/web/frontend/src/api/pico.ts
+++ b/web/frontend/src/api/pico.ts
@@ -2,16 +2,16 @@ import { launcherFetch } from "@/api/http"
// API client for Pico Channel configuration.
-interface PicoTokenResponse {
- token: string
+interface PicoInfoResponse {
ws_url: string
enabled: boolean
+ configured?: boolean
}
interface PicoSetupResponse {
- token: string
ws_url: string
enabled: boolean
+ configured?: boolean
changed: boolean
}
@@ -25,16 +25,16 @@ async function request(path: string, options?: RequestInit): Promise {
return res.json() as Promise
}
-export async function getPicoToken(): Promise {
- return request("/api/pico/token")
+export async function getPicoInfo(): Promise {
+ return request("/api/pico/info")
}
-export async function regenPicoToken(): Promise {
- return request("/api/pico/token", { method: "POST" })
+export async function regenPicoToken(): Promise {
+ return request("/api/pico/token", { method: "POST" })
}
export async function setupPico(): Promise {
return request("/api/pico/setup", { method: "POST" })
}
-export type { PicoTokenResponse, PicoSetupResponse }
+export type { PicoInfoResponse, PicoSetupResponse }
diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts
index c91495901..edd7d7c27 100644
--- a/web/frontend/src/api/sessions.ts
+++ b/web/frontend/src/api/sessions.ts
@@ -1,5 +1,3 @@
-// Sessions API — list and retrieve chat session history
-
import { launcherFetch } from "@/api/http"
export interface SessionSummary {
@@ -13,7 +11,29 @@ export interface SessionSummary {
export interface SessionDetail {
id: string
- messages: { role: "user" | "assistant"; content: string }[]
+ messages: {
+ role: "user" | "assistant"
+ content: string
+ kind?: "normal" | "thought" | "tool_calls"
+ media?: string[]
+ attachments?: {
+ type?: "image" | "audio" | "video" | "file"
+ url: string
+ filename?: string
+ content_type?: string
+ }[]
+ tool_calls?: {
+ id?: string
+ type?: string
+ function?: {
+ name?: string
+ arguments?: string
+ }
+ extra_content?: {
+ tool_feedback_explanation?: string
+ }
+ }[]
+ }[]
summary: string
created: string
updated: string
diff --git a/web/frontend/src/api/skills.ts b/web/frontend/src/api/skills.ts
index 72ccbcfe5..958808afd 100644
--- a/web/frontend/src/api/skills.ts
+++ b/web/frontend/src/api/skills.ts
@@ -5,22 +5,60 @@ export interface SkillSupportItem {
path: string
source: "workspace" | "global" | "builtin" | string
description: string
+ origin_kind: "builtin" | "third_party" | "manual" | string
+ registry_name?: string
+ registry_url?: string
+ installed_version?: string
+ installed_at?: number
}
export interface SkillDetailResponse extends SkillSupportItem {
content: string
}
+export interface SkillRegistrySearchResult {
+ score: number
+ slug: string
+ display_name: string
+ summary: string
+ version: string
+ registry_name: string
+ url?: string
+ installed: boolean
+ installed_name?: string
+}
+
interface SkillsResponse {
skills: SkillSupportItem[]
}
-interface SkillActionResponse {
+export interface SkillSearchResponse {
+ results: SkillRegistrySearchResult[]
+ limit: number
+ offset: number
+ next_offset?: number
+ has_more: boolean
+}
+
+type SkillActionResponse = Partial & {
status?: string
- name?: string
- path?: string
- source?: string
- description?: string
+}
+
+export interface InstallSkillRequest {
+ slug: string
+ registry: string
+ version?: string
+ force?: boolean
+}
+
+export interface InstallSkillResponse {
+ status: string
+ slug: string
+ registry: string
+ version: string
+ summary?: string
+ is_suspicious?: boolean
+ skill?: SkillSupportItem
}
async function request(path: string, options?: RequestInit): Promise {
@@ -39,6 +77,29 @@ export async function getSkill(name: string): Promise {
return request(`/api/skills/${encodeURIComponent(name)}`)
}
+export async function searchSkills(
+ query: string,
+ limit = 20,
+ offset = 0,
+): Promise {
+ const params = new URLSearchParams({
+ q: query,
+ limit: String(limit),
+ offset: String(offset),
+ })
+ return request(`/api/skills/search?${params.toString()}`)
+}
+
+export async function installSkill(
+ input: InstallSkillRequest,
+): Promise {
+ return request("/api/skills/install", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(input),
+ })
+}
+
export async function importSkill(file: File): Promise {
const formData = new FormData()
formData.set("file", file)
@@ -64,15 +125,23 @@ export async function deleteSkill(name: string): Promise {
async function extractErrorMessage(res: Response): Promise {
try {
- const body = (await res.json()) as {
- error?: string
- errors?: string[]
+ const raw = await res.text()
+ if (raw.trim() === "") {
+ return `API error: ${res.status} ${res.statusText}`
}
- if (Array.isArray(body.errors) && body.errors.length > 0) {
- return body.errors.join("; ")
- }
- if (typeof body.error === "string" && body.error.trim() !== "") {
- return body.error
+ try {
+ const body = JSON.parse(raw) as {
+ error?: string
+ errors?: string[]
+ }
+ if (Array.isArray(body.errors) && body.errors.length > 0) {
+ return body.errors.join("; ")
+ }
+ if (typeof body.error === "string" && body.error.trim() !== "") {
+ return body.error
+ }
+ } catch {
+ return raw.trim()
}
} catch {
// ignore invalid body
diff --git a/web/frontend/src/api/system.ts b/web/frontend/src/api/system.ts
index 2e2f36f15..dfc48b6b8 100644
--- a/web/frontend/src/api/system.ts
+++ b/web/frontend/src/api/system.ts
@@ -13,6 +13,13 @@ export interface LauncherConfig {
allowed_cidrs: string[]
}
+export interface SystemVersionInfo {
+ version: string
+ git_commit?: string
+ build_time?: string
+ go_version: string
+}
+
async function request(path: string, options?: RequestInit): Promise {
const res = await launcherFetch(path, options)
if (!res.ok) {
@@ -62,3 +69,7 @@ export async function setLauncherConfig(
body: JSON.stringify(payload),
})
}
+
+export async function getSystemVersionInfo(): Promise {
+ return request("/api/system/version")
+}
diff --git a/web/frontend/src/api/tools.ts b/web/frontend/src/api/tools.ts
index 824bcc0fa..a77f3ba80 100644
--- a/web/frontend/src/api/tools.ts
+++ b/web/frontend/src/api/tools.ts
@@ -17,6 +17,31 @@ interface ToolActionResponse {
status: string
}
+export interface WebSearchProviderOption {
+ id: string
+ label: string
+ configured: boolean
+ current: boolean
+ requires_auth: boolean
+}
+
+export interface WebSearchProviderConfig {
+ enabled: boolean
+ max_results: number
+ base_url?: string
+ api_key?: string
+ api_key_set?: boolean
+}
+
+export interface WebSearchConfigResponse {
+ provider: string
+ current_service: string
+ prefer_native: boolean
+ proxy?: string
+ providers: WebSearchProviderOption[]
+ settings: Record
+}
+
async function request(path: string, options?: RequestInit): Promise {
const res = await launcherFetch(path, options)
if (!res.ok) {
@@ -56,3 +81,17 @@ export async function setToolEnabled(
},
)
}
+
+export async function getWebSearchConfig(): Promise {
+ return request("/api/tools/web-search-config")
+}
+
+export async function updateWebSearchConfig(
+ payload: WebSearchConfigResponse,
+): Promise {
+ return request("/api/tools/web-search-config", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ })
+}
diff --git a/web/frontend/src/app-providers.tsx b/web/frontend/src/app-providers.tsx
new file mode 100644
index 000000000..bfb5dfb38
--- /dev/null
+++ b/web/frontend/src/app-providers.tsx
@@ -0,0 +1,13 @@
+import type { ReactNode } from "react"
+
+import { useHighlightTheme } from "./hooks/use-highlight-theme"
+
+interface AppProvidersProps {
+ children: ReactNode
+}
+
+export function AppProviders({ children }: AppProvidersProps) {
+ useHighlightTheme()
+
+ return <>{children}>
+}
diff --git a/web/frontend/src/components/agent/hub/hub-page.tsx b/web/frontend/src/components/agent/hub/hub-page.tsx
new file mode 100644
index 000000000..69f0be638
--- /dev/null
+++ b/web/frontend/src/components/agent/hub/hub-page.tsx
@@ -0,0 +1,51 @@
+import { useTranslation } from "react-i18next"
+
+import { PageHeader } from "@/components/page-header"
+
+import { ResultsPanel } from "./results-panel"
+import { SearchPanel } from "./search-panel"
+import { useHubMarketplace } from "./use-hub-marketplace"
+
+export function HubPage() {
+ const { t } = useTranslation()
+ const hub = useHubMarketplace()
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/hub/market-skill-card.tsx b/web/frontend/src/components/agent/hub/market-skill-card.tsx
new file mode 100644
index 000000000..99b00db92
--- /dev/null
+++ b/web/frontend/src/components/agent/hub/market-skill-card.tsx
@@ -0,0 +1,158 @@
+import {
+ IconCheck,
+ IconFileInfo,
+ IconLoader2,
+ IconPlus,
+} from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import {
+ type SkillRegistrySearchResult,
+ type SkillSupportItem,
+} from "@/api/skills"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+export function MarketSkillCard({
+ result,
+ canInstall,
+ installPending,
+ installedSkill,
+ onInstall,
+ onViewInstalled,
+}: {
+ result: SkillRegistrySearchResult
+ canInstall: boolean
+ installPending: boolean
+ installedSkill: SkillSupportItem | null
+ onInstall: () => void
+ onViewInstalled: () => void
+}) {
+ const { t } = useTranslation()
+
+ const installDisabledReason = (() => {
+ if (installPending)
+ return t("pages.agent.skills.marketplace_installDisabled.installing")
+ if (result.installed)
+ return t("pages.agent.skills.marketplace_installDisabled.installed")
+ if (!canInstall)
+ return t("pages.agent.skills.marketplace_installDisabled.cannotInstall")
+ return t("pages.agent.skills.marketplace_install_action")
+ })()
+ const installDisabled = !canInstall || result.installed || installPending
+
+ return (
+
+ {result.installed && (
+
+ )}
+
+
+
+
+
+ {result.display_name || result.slug}
+
+
+ {result.registry_name}
+
+ {result.installed ? (
+
+ {t("pages.agent.skills.marketplace_installed")}
+
+ ) : null}
+
+
+ {result.slug}
+ {result.version ? (
+
+ {" "}
+ · v{result.version}
+
+ ) : null}
+
+
+ {result.summary}
+
+ {result.url ? (
+
+
+ {result.url}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {installDisabledReason}
+
+ {result.installed && installedSkill ? (
+
+ ) : null}
+
+
+
+ {result.installed_name ? (
+
+
+ {t("pages.agent.skills.marketplace_installed_hint", {
+ name: result.installed_name,
+ })}
+
+
+ ) : null}
+
+ )
+}
diff --git a/web/frontend/src/components/agent/hub/results-panel.tsx b/web/frontend/src/components/agent/hub/results-panel.tsx
new file mode 100644
index 000000000..e2a351955
--- /dev/null
+++ b/web/frontend/src/components/agent/hub/results-panel.tsx
@@ -0,0 +1,135 @@
+import { IconLoader2, IconSearch, IconX } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import {
+ type SkillRegistrySearchResult,
+ type SkillSupportItem,
+} from "@/api/skills"
+
+import { MarketSkillCard } from "./market-skill-card"
+
+export function ResultsPanel({
+ canSearchMarketplace,
+ hasSubmittedQuery,
+ submittedQuery,
+ marketResults,
+ marketSearchError,
+ isMarketSearchInitialLoading,
+ isMarketSearchLoadingMore,
+ canInstallFromMarketplace,
+ getInstalledSkill,
+ isInstallPending,
+ onInstall,
+ onViewInstalled,
+}: {
+ canSearchMarketplace: boolean
+ hasSubmittedQuery: boolean
+ submittedQuery: string
+ marketResults: SkillRegistrySearchResult[]
+ marketSearchError: unknown
+ isMarketSearchInitialLoading: boolean
+ isMarketSearchLoadingMore: boolean
+ canInstallFromMarketplace: boolean
+ getInstalledSkill: (installedName?: string) => SkillSupportItem | null
+ isInstallPending: (result: SkillRegistrySearchResult) => boolean
+ onInstall: (result: SkillRegistrySearchResult) => void
+ onViewInstalled: () => void
+}) {
+ const { t } = useTranslation()
+
+ return (
+
+
+ {canSearchMarketplace && hasSubmittedQuery ? (
+
+
+
+ {t("pages.agent.skills.marketplace_notice_title")}
+
+
+ {t("pages.agent.skills.marketplace_notice_body")}
+
+
+
+ {isMarketSearchInitialLoading ? (
+
+
+
+ {t("pages.agent.skills.marketplace_loading_results")}
+
+
+ ) : marketSearchError ? (
+
+
+
+
+ {marketSearchError instanceof Error
+ ? marketSearchError.message
+ : t("pages.agent.skills.marketplace_search_error")}
+
+
+
+ ) : marketResults.length ? (
+
+
+
+ {t("pages.agent.skills.marketplace_results_title", {
+ query: submittedQuery,
+ count: marketResults.length,
+ })}
+
+
+ {t("pages.agent.skills.marketplace_results_hint")}
+
+
+
+ {marketResults.map((result) => (
+ onInstall(result)}
+ onViewInstalled={onViewInstalled}
+ />
+ ))}
+
+ {isMarketSearchLoadingMore ? (
+
+
+
+ {t("pages.agent.skills.marketplace_loading_more")}
+
+
+ ) : null}
+
+ ) : (
+
+
+
+ {t("pages.agent.skills.marketplace_empty_results", {
+ query: submittedQuery,
+ })}
+
+
+ )}
+
+ ) : !canSearchMarketplace ? (
+
+
+ {t("pages.agent.skills.marketplace_unavailable")}
+
+
+ ) : (
+
+
+
+ {t("pages.agent.skills.marketplace_idle")}
+
+
+ )}
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/hub/search-panel.tsx b/web/frontend/src/components/agent/hub/search-panel.tsx
new file mode 100644
index 000000000..875aaad6b
--- /dev/null
+++ b/web/frontend/src/components/agent/hub/search-panel.tsx
@@ -0,0 +1,91 @@
+import { IconLoader2 } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+
+import type { UnavailableToolMessage } from "./tool-support"
+
+export function SearchPanel({
+ marketQuery,
+ canSearchMarketplace,
+ isMarketSearchInitialLoading,
+ unavailableToolMessages,
+ onMarketQueryChange,
+ onSearchSubmit,
+}: {
+ marketQuery: string
+ canSearchMarketplace: boolean
+ isMarketSearchInitialLoading: boolean
+ unavailableToolMessages: UnavailableToolMessage[]
+ onMarketQueryChange: (value: string) => void
+ onSearchSubmit: () => void
+}) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+ {t("pages.agent.skills.marketplace_title", {
+ defaultValue: "Discover Skills",
+ })}
+
+
+ {t("pages.agent.skills.marketplace_description")}
+
+
+
+
+
+ {unavailableToolMessages.length ? (
+
+ {unavailableToolMessages.map((item) => (
+
+ {item.label}
+ {item.message}
+
+ ))}
+
+ ) : null}
+
+ )
+}
diff --git a/web/frontend/src/components/agent/hub/tool-support.ts b/web/frontend/src/components/agent/hub/tool-support.ts
new file mode 100644
index 000000000..1553b156a
--- /dev/null
+++ b/web/frontend/src/components/agent/hub/tool-support.ts
@@ -0,0 +1,56 @@
+import type { TFunction } from "i18next"
+
+import type { ToolSupportItem } from "@/api/tools"
+
+type MarketplaceTool =
+ | Pick
+ | undefined
+
+export interface UnavailableToolMessage {
+ key: "search" | "install"
+ label: string
+ message: string
+}
+
+export function buildUnavailableToolMessages({
+ searchTool,
+ installTool,
+ t,
+}: {
+ searchTool: MarketplaceTool
+ installTool: MarketplaceTool
+ t: TFunction
+}): UnavailableToolMessage[] {
+ const searchMessage = getToolSupportMessage(searchTool, t)
+ const installMessage = getToolSupportMessage(installTool, t)
+
+ return [
+ searchMessage
+ ? {
+ key: "search",
+ label: t("pages.agent.skills.marketplace_search_status"),
+ message: searchMessage,
+ }
+ : null,
+ installMessage
+ ? {
+ key: "install",
+ label: t("pages.agent.skills.marketplace_install_status"),
+ message: installMessage,
+ }
+ : null,
+ ].filter((item): item is UnavailableToolMessage => Boolean(item))
+}
+
+function getToolSupportMessage(
+ tool: MarketplaceTool,
+ t: TFunction,
+): string | null {
+ if (!tool || tool.status === "enabled") {
+ return null
+ }
+ if (tool.reason_code) {
+ return `${t(`pages.agent.tools.reasons.${tool.reason_code}`)} ${t("pages.agent.skills.marketplace_status_enable_hint")}`
+ }
+ return t("pages.agent.skills.marketplace_status_disabled")
+}
diff --git a/web/frontend/src/components/agent/hub/use-hub-marketplace.ts b/web/frontend/src/components/agent/hub/use-hub-marketplace.ts
new file mode 100644
index 000000000..2777aa376
--- /dev/null
+++ b/web/frontend/src/components/agent/hub/use-hub-marketplace.ts
@@ -0,0 +1,211 @@
+import {
+ useInfiniteQuery,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query"
+import { useNavigate } from "@tanstack/react-router"
+import { type UIEvent, useEffect, useRef, useState } from "react"
+import { useTranslation } from "react-i18next"
+import { toast } from "sonner"
+
+import {
+ type SkillRegistrySearchResult,
+ type SkillSearchResponse,
+ type SkillSupportItem,
+ getSkills,
+ installSkill,
+ searchSkills,
+} from "@/api/skills"
+import { getTools } from "@/api/tools"
+
+import { buildUnavailableToolMessages } from "./tool-support"
+
+const MARKET_SEARCH_LIMIT = 20
+
+export function useHubMarketplace() {
+ const { t } = useTranslation()
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+ const isLoadMoreLockedRef = useRef(false)
+
+ const [marketQuery, setMarketQuery] = useState("")
+ const [submittedMarketQuery, setSubmittedMarketQuery] = useState("")
+
+ const { data: skillsData } = useQuery({
+ queryKey: ["skills"],
+ queryFn: getSkills,
+ })
+ const { data: toolsData } = useQuery({
+ queryKey: ["tools"],
+ queryFn: getTools,
+ })
+
+ const findSkillsTool = toolsData?.tools.find(
+ (tool) => tool.name === "find_skills",
+ )
+ const installSkillTool = toolsData?.tools.find(
+ (tool) => tool.name === "install_skill",
+ )
+ const canSearchMarketplace = findSkillsTool?.status === "enabled"
+ const canInstallFromMarketplace = installSkillTool?.status === "enabled"
+ const hasSubmittedQuery = submittedMarketQuery.trim() !== ""
+ const isMarketSearchActive = canSearchMarketplace && hasSubmittedQuery
+
+ const {
+ data: marketSearchData,
+ isPending: isMarketSearchPending,
+ isFetching: isMarketSearchFetching,
+ isFetchingNextPage,
+ error: marketSearchError,
+ hasNextPage,
+ fetchNextPage,
+ refetch: refetchMarketSearch,
+ } = useInfiniteQuery({
+ queryKey: ["skills-marketplace", submittedMarketQuery],
+ initialPageParam: 0,
+ queryFn: ({ pageParam }) =>
+ searchSkills(
+ submittedMarketQuery,
+ MARKET_SEARCH_LIMIT,
+ Number(pageParam) || 0,
+ ),
+ getNextPageParam: (lastPage: SkillSearchResponse) =>
+ lastPage.has_more ? (lastPage.next_offset ?? undefined) : undefined,
+ enabled: isMarketSearchActive,
+ staleTime: 5 * 60 * 1000,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ })
+
+ const installMutation = useMutation({
+ mutationFn: installSkill,
+ onSuccess: (response) => {
+ toast.success(
+ t("pages.agent.skills.install_success", {
+ name: response.skill?.name ?? response.slug,
+ }),
+ )
+ void queryClient.invalidateQueries({ queryKey: ["skills"] })
+ void queryClient.invalidateQueries({ queryKey: ["skills-marketplace"] })
+ },
+ onError: (err) => {
+ toast.error(
+ err instanceof Error
+ ? err.message
+ : t("pages.agent.skills.install_error"),
+ )
+ },
+ })
+
+ const allSkills = skillsData?.skills ?? []
+ const workspaceSkillsByName = new Map(
+ allSkills
+ .filter((skill) => skill.source === "workspace")
+ .map((skill) => [skill.name, skill] as const),
+ )
+ const marketResults =
+ marketSearchData?.pages.flatMap((page) => page.results) ?? []
+ const hasMoreMarketResults = hasNextPage ?? false
+ const isMarketSearchInitialLoading =
+ isMarketSearchActive &&
+ !marketSearchData &&
+ (isMarketSearchPending || isMarketSearchFetching)
+ const isMarketSearchLoadingMore =
+ isMarketSearchActive && Boolean(marketSearchData) && isFetchingNextPage
+ const installPendingKey =
+ installMutation.isPending && installMutation.variables
+ ? `${installMutation.variables.registry}:${installMutation.variables.slug}`
+ : null
+
+ const unavailableToolMessages = buildUnavailableToolMessages({
+ searchTool: findSkillsTool,
+ installTool: installSkillTool,
+ t,
+ })
+
+ useEffect(() => {
+ if (!isFetchingNextPage) {
+ isLoadMoreLockedRef.current = false
+ }
+ }, [isFetchingNextPage])
+
+ const handleSearchSubmit = () => {
+ const nextQuery = marketQuery.trim()
+ if (!canSearchMarketplace || nextQuery === "") {
+ return
+ }
+
+ isLoadMoreLockedRef.current = false
+ if (nextQuery === submittedMarketQuery) {
+ void refetchMarketSearch()
+ return
+ }
+
+ setSubmittedMarketQuery(nextQuery)
+ }
+
+ const handleInstall = (result: SkillRegistrySearchResult) => {
+ installMutation.mutate({
+ slug: result.slug,
+ registry: result.registry_name,
+ version: result.version || undefined,
+ })
+ }
+
+ const handleViewInstalled = () => {
+ void navigate({ to: "/agent/skills" })
+ }
+
+ const handleScroll = (event: UIEvent) => {
+ if (
+ !isMarketSearchActive ||
+ !hasMoreMarketResults ||
+ isFetchingNextPage ||
+ isLoadMoreLockedRef.current
+ ) {
+ return
+ }
+
+ const node = event.currentTarget
+ const remaining = node.scrollHeight - node.scrollTop - node.clientHeight
+ if (remaining > 240) {
+ return
+ }
+
+ isLoadMoreLockedRef.current = true
+ void fetchNextPage()
+ }
+
+ const getInstalledSkill = (
+ installedName?: string,
+ ): SkillSupportItem | null => {
+ if (!installedName) {
+ return null
+ }
+ return workspaceSkillsByName.get(installedName) ?? null
+ }
+
+ const isInstallPending = (result: SkillRegistrySearchResult) =>
+ installPendingKey === `${result.registry_name}:${result.slug}`
+
+ return {
+ marketQuery,
+ submittedMarketQuery,
+ canSearchMarketplace,
+ canInstallFromMarketplace,
+ marketResults,
+ marketSearchError,
+ unavailableToolMessages,
+ hasSubmittedQuery,
+ isMarketSearchInitialLoading,
+ isMarketSearchLoadingMore,
+ setMarketQuery,
+ handleSearchSubmit,
+ handleInstall,
+ handleViewInstalled,
+ handleScroll,
+ getInstalledSkill,
+ isInstallPending,
+ }
+}
diff --git a/web/frontend/src/components/agent/skills/delete-dialog.tsx b/web/frontend/src/components/agent/skills/delete-dialog.tsx
new file mode 100644
index 000000000..1f4eba4c3
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/delete-dialog.tsx
@@ -0,0 +1,66 @@
+import { IconLoader2, IconTrash } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import type { SkillSupportItem } from "@/api/skills"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+
+interface DeleteDialogProps {
+ open: boolean
+ skillPendingDelete: SkillSupportItem | null
+ isDeletePending: boolean
+ onOpenChange: (open: boolean) => void
+ onConfirm: () => void
+}
+
+export function DeleteDialog({
+ open,
+ skillPendingDelete,
+ isDeletePending,
+ onOpenChange,
+ onConfirm,
+}: DeleteDialogProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
+ {t("pages.agent.skills.delete_title")}
+
+
+ {t("pages.agent.skills.delete_description", {
+ name: skillPendingDelete?.name,
+ })}
+
+
+
+
+ {t("common.cancel")}
+
+
+ {isDeletePending ? (
+
+ ) : (
+
+ )}
+ {t("pages.agent.skills.delete_confirm")}
+
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/detail-sheet.tsx b/web/frontend/src/components/agent/skills/detail-sheet.tsx
new file mode 100644
index 000000000..4579926d8
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/detail-sheet.tsx
@@ -0,0 +1,248 @@
+import {
+ IconFileCode,
+ IconSparkles,
+ IconWorld,
+ IconX,
+} from "@tabler/icons-react"
+import type { ReactNode } from "react"
+import { useTranslation } from "react-i18next"
+import ReactMarkdown from "react-markdown"
+import rehypeHighlight from "rehype-highlight"
+import rehypeRaw from "rehype-raw"
+import rehypeSanitize from "rehype-sanitize"
+import remarkGfm from "remark-gfm"
+
+import type { SkillDetailResponse, SkillSupportItem } from "@/api/skills"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Skeleton } from "@/components/ui/skeleton"
+import { cn } from "@/lib/utils"
+
+import { OriginBadge } from "./origin-badge"
+import { getOriginLabel, getSkillOriginKind } from "./origin-utils"
+import type { SkillDetailView } from "./types"
+
+const DETAIL_VIEWS = [
+ "preview",
+ "raw",
+ "meta",
+] as const satisfies SkillDetailView[]
+
+interface DetailSheetProps {
+ open: boolean
+ selectedSkill: SkillSupportItem | null
+ selectedSkillDetail?: SkillDetailResponse
+ isLoading: boolean
+ error: unknown
+ detailView: SkillDetailView
+ onDetailViewChange: (view: SkillDetailView) => void
+ onOpenChange: (open: boolean) => void
+}
+
+export function DetailSheet({
+ open,
+ selectedSkill,
+ selectedSkillDetail,
+ isLoading,
+ error,
+ detailView,
+ onDetailViewChange,
+ onOpenChange,
+}: DetailSheetProps) {
+ const { t } = useTranslation()
+
+ const activeSkillDetail = selectedSkillDetail ?? selectedSkill
+ const activeSkillOrigin = activeSkillDetail
+ ? getSkillOriginKind(activeSkillDetail)
+ : null
+ const detailLineCount = selectedSkillDetail
+ ? selectedSkillDetail.content.split("\n").length
+ : 0
+ const detailCharacterCount = selectedSkillDetail?.content.length ?? 0
+
+ return (
+
+
+
+
+
+ {activeSkillDetail?.origin_kind === "builtin" ? (
+
+ ) : activeSkillDetail?.registry_name ? (
+
+ ) : (
+
+ )}
+
+
+
+ {activeSkillDetail?.name ||
+ t("pages.agent.skills.viewer_title")}
+
+
+ {activeSkillDetail?.description ||
+ t("pages.agent.skills.viewer_description")}
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+
+
+ ) : error ? (
+
+
+
+ {t("pages.agent.skills.load_detail_error")}
+
+
+ ) : selectedSkillDetail ? (
+
+ {activeSkillOrigin === "third_party" ? (
+
+
+
+
+
+
+ {selectedSkillDetail.registry_name ? (
+
+ ) : null}
+ {selectedSkillDetail.installed_version ? (
+
+ ) : null}
+ {selectedSkillDetail.registry_url ? (
+
+ {selectedSkillDetail.registry_url}
+
+ }
+ mono
+ />
+ ) : null}
+
+
+ ) : null}
+
+
+ {DETAIL_VIEWS.map((view) => (
+
+ ))}
+
+
+ {detailView === "preview" ? (
+
+
+ {selectedSkillDetail.content}
+
+
+ ) : null}
+
+ {detailView === "raw" ? (
+
+
+ {selectedSkillDetail.content}
+
+
+ ) : null}
+
+ {detailView === "meta" ? (
+
+
+
+
+
+
+ ) : null}
+
+ ) : null}
+
+
+
+ )
+}
+
+function MetadataItem({
+ label,
+ value,
+ mono = false,
+}: {
+ label: string
+ value: ReactNode
+ mono?: boolean
+}) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/filter-bar.tsx b/web/frontend/src/components/agent/skills/filter-bar.tsx
new file mode 100644
index 000000000..033609ea6
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/filter-bar.tsx
@@ -0,0 +1,132 @@
+import { IconLayoutGrid, IconLayoutList, IconSearch } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { cn } from "@/lib/utils"
+
+import { getOriginLabel } from "./origin-utils"
+import type { SkillLayoutMode, SkillSortOption } from "./types"
+
+interface FilterBarProps {
+ searchQuery: string
+ sourceFilter: string
+ availableOrigins: string[]
+ sortOrder: SkillSortOption
+ layoutMode: SkillLayoutMode
+ onSearchQueryChange: (value: string) => void
+ onSourceFilterChange: (value: string) => void
+ onSortOrderChange: (value: SkillSortOption) => void
+ onLayoutModeChange: (value: SkillLayoutMode) => void
+}
+
+export function FilterBar({
+ searchQuery,
+ sourceFilter,
+ availableOrigins,
+ sortOrder,
+ layoutMode,
+ onSearchQueryChange,
+ onSourceFilterChange,
+ onSortOrderChange,
+ onLayoutModeChange,
+}: FilterBarProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+ onSearchQueryChange(event.target.value)}
+ placeholder={t("pages.agent.skills.search_placeholder")}
+ className="hover:bg-background/50 focus-visible:bg-background h-9 border-transparent bg-transparent pl-9 shadow-none focus-visible:ring-1"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/import-dialog.tsx b/web/frontend/src/components/agent/skills/import-dialog.tsx
new file mode 100644
index 000000000..21f4827e3
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/import-dialog.tsx
@@ -0,0 +1,160 @@
+import { IconLoader2, IconUpload, IconX } from "@tabler/icons-react"
+import type { DragEvent } from "react"
+import { useTranslation } from "react-i18next"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { cn } from "@/lib/utils"
+
+interface ImportDialogProps {
+ open: boolean
+ isImportPending: boolean
+ isDragActive: boolean
+ onOpenChange: (open: boolean) => void
+ onImportClick: () => void
+ onDragEnter: (event: DragEvent) => void
+ onDragLeave: (event: DragEvent) => void
+ onDrop: (event: DragEvent) => void
+}
+
+export function ImportDialog({
+ open,
+ isImportPending,
+ isDragActive,
+ onOpenChange,
+ onImportClick,
+ onDragEnter,
+ onDragLeave,
+ onDrop,
+}: ImportDialogProps) {
+ const { t } = useTranslation()
+
+ return (
+
+ )
+}
+
+function SkillImportPanel({
+ isDragActive,
+ isImportPending,
+ onDragEnter,
+ onDragLeave,
+ onDrop,
+ onImportClick,
+}: {
+ isDragActive: boolean
+ isImportPending: boolean
+ onDragEnter: (event: DragEvent) => void
+ onDragLeave: (event: DragEvent) => void
+ onDrop: (event: DragEvent) => void
+ onImportClick: () => void
+}) {
+ const { t } = useTranslation()
+
+ return (
+
+ {
+ if (!isImportPending) {
+ onImportClick()
+ }
+ }}
+ onDragEnter={onDragEnter}
+ onDragLeave={onDragLeave}
+ onDragOver={(event) => event.preventDefault()}
+ onDrop={onDrop}
+ >
+
+
+
+
+
+ {isDragActive
+ ? t("pages.agent.skills.dropzone_active")
+ : t("pages.agent.skills.dropzone_label")}
+
+
+ {isDragActive
+ ? t("pages.agent.skills.dropzone_release")
+ : t("pages.agent.skills.import_constraints")}
+
+
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/origin-badge.tsx b/web/frontend/src/components/agent/skills/origin-badge.tsx
new file mode 100644
index 000000000..0b7bf4391
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/origin-badge.tsx
@@ -0,0 +1,46 @@
+import {
+ IconFileCode,
+ IconFolder,
+ IconSparkles,
+ IconWorld,
+} from "@tabler/icons-react"
+
+import { cn } from "@/lib/utils"
+
+import { getOriginBadgeClasses } from "./origin-utils"
+
+export function OriginBadge({
+ origin,
+ label,
+}: {
+ origin: string
+ label: string
+}) {
+ return (
+
+
+ {label}
+
+ )
+}
+
+export function OriginIcon({ origin }: { origin: string }) {
+ if (origin === "builtin") {
+ return
+ }
+ if (origin === "third_party") {
+ return
+ }
+ if (origin === "manual") {
+ return
+ }
+ if (origin === "all") {
+ return
+ }
+ return
+}
diff --git a/web/frontend/src/components/agent/skills/origin-utils.ts b/web/frontend/src/components/agent/skills/origin-utils.ts
new file mode 100644
index 000000000..6163f7bf7
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/origin-utils.ts
@@ -0,0 +1,86 @@
+import type { TFunction } from "i18next"
+
+import type { SkillSupportItem } from "@/api/skills"
+
+import type { SkillSortOption } from "./types"
+
+const KNOWN_ORIGIN_ORDER = ["builtin", "third_party", "manual"]
+
+export function compareSkills(
+ left: SkillSupportItem,
+ right: SkillSupportItem,
+ sortOrder: SkillSortOption,
+) {
+ if (sortOrder === "source") {
+ const sourceDelta = compareOriginOrder(
+ getSkillOriginKind(left),
+ getSkillOriginKind(right),
+ )
+ if (sourceDelta !== 0) return sourceDelta
+ return left.name.localeCompare(right.name)
+ }
+
+ if (sortOrder === "name-desc") {
+ return right.name.localeCompare(left.name)
+ }
+
+ return left.name.localeCompare(right.name)
+}
+
+export function sortOrigins(origins: string[]) {
+ return [...origins].sort(compareOriginOrder)
+}
+
+export function getSkillOriginKind(skill: SkillSupportItem) {
+ const origin = skill.origin_kind || skill.source
+ return origin === "global" ? "builtin" : origin
+}
+
+export function getOriginLabel(origin: string, t: TFunction) {
+ if (origin === "builtin" || origin === "third_party" || origin === "manual") {
+ return t(`pages.agent.skills.origin.${origin}`)
+ }
+ if (origin === "all") {
+ return t("pages.agent.skills.origin.all")
+ }
+ return origin
+}
+
+export function getOriginAccentClasses(origin: string) {
+ if (origin === "manual") {
+ return "bg-emerald-100 text-emerald-700"
+ }
+ if (origin === "third_party") {
+ return "bg-sky-100 text-sky-700"
+ }
+ if (origin === "builtin") {
+ return "bg-amber-100 text-amber-700"
+ }
+ return "bg-muted text-muted-foreground"
+}
+
+export function getOriginBadgeClasses(origin: string) {
+ if (origin === "manual") {
+ return "bg-emerald-100 text-emerald-700"
+ }
+ if (origin === "third_party") {
+ return "bg-sky-100 text-sky-700"
+ }
+ if (origin === "builtin") {
+ return "bg-amber-100 text-amber-700"
+ }
+ return "bg-muted text-muted-foreground"
+}
+
+function compareOriginOrder(left: string, right: string) {
+ const leftIndex = KNOWN_ORIGIN_ORDER.indexOf(left)
+ const rightIndex = KNOWN_ORIGIN_ORDER.indexOf(right)
+
+ if (leftIndex !== -1 || rightIndex !== -1) {
+ if (leftIndex === -1) return 1
+ if (rightIndex === -1) return -1
+ return leftIndex - rightIndex
+ }
+
+ return left.localeCompare(right)
+}
diff --git a/web/frontend/src/components/agent/skills/page-skeleton.tsx b/web/frontend/src/components/agent/skills/page-skeleton.tsx
new file mode 100644
index 000000000..73df6fcdf
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/page-skeleton.tsx
@@ -0,0 +1,27 @@
+import { Skeleton } from "@/components/ui/skeleton"
+
+export function PageSkeleton() {
+ return (
+
+
+ {[1, 2, 3, 4].map((index) => (
+
+ ))}
+
+
+
+
+
+
+
+ {[1, 2, 3, 4].map((index) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/skill-card.tsx b/web/frontend/src/components/agent/skills/skill-card.tsx
new file mode 100644
index 000000000..15bdc2c63
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/skill-card.tsx
@@ -0,0 +1,84 @@
+import { IconFileInfo, IconTrash } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import type { SkillSupportItem } from "@/api/skills"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+
+interface SkillCardProps {
+ skill: SkillSupportItem
+ onView: () => void
+ onDelete: () => void
+}
+
+export function SkillCard({ skill, onView, onDelete }: SkillCardProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
+
+
+
+ {skill.name}
+
+ {skill.registry_name ? (
+
+ {skill.registry_name}
+
+ ) : null}
+
+
+ {skill.description || t("pages.agent.skills.no_description")}
+
+
+
+
+ {skill.source === "workspace" ? (
+
+ ) : null}
+
+
+
+
+ {skill.registry_url ? (
+
+ {skill.registry_url}
+
+ ) : null}
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/skills-list.tsx b/web/frontend/src/components/agent/skills/skills-list.tsx
new file mode 100644
index 000000000..6a2bb92ed
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/skills-list.tsx
@@ -0,0 +1,86 @@
+import { IconSearch } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import type { SkillSupportItem } from "@/api/skills"
+
+import { OriginBadge } from "./origin-badge"
+import { getOriginLabel } from "./origin-utils"
+import { SkillCard } from "./skill-card"
+import type { SkillGroupSection, SkillLayoutMode } from "./types"
+
+interface SkillsListProps {
+ sortedSkills: SkillSupportItem[]
+ groupedSkills: SkillGroupSection[]
+ layoutMode: SkillLayoutMode
+ sourceFilter: string
+ hasActiveFilters: boolean
+ onViewSkill: (skill: SkillSupportItem) => void
+ onDeleteSkill: (skill: SkillSupportItem) => void
+}
+
+export function SkillsList({
+ sortedSkills,
+ groupedSkills,
+ layoutMode,
+ sourceFilter,
+ hasActiveFilters,
+ onViewSkill,
+ onDeleteSkill,
+}: SkillsListProps) {
+ const { t } = useTranslation()
+
+ if (!sortedSkills.length) {
+ return (
+
+
+
+
+
+ {hasActiveFilters
+ ? t("pages.agent.skills.no_results")
+ : t("pages.agent.skills.empty")}
+
+
+ )
+ }
+
+ if (layoutMode === "grouped" && sourceFilter === "all") {
+ return (
+
+ {groupedSkills.map((section) => (
+
+
+
+
+
+ {section.skills.map((skill) => (
+ onViewSkill(skill)}
+ onDelete={() => onDeleteSkill(skill)}
+ />
+ ))}
+
+
+ ))}
+
+ )
+ }
+
+ return (
+
+ {sortedSkills.map((skill) => (
+ onViewSkill(skill)}
+ onDelete={() => onDeleteSkill(skill)}
+ />
+ ))}
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/skills-page.tsx b/web/frontend/src/components/agent/skills/skills-page.tsx
new file mode 100644
index 000000000..d9b5a7cd1
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/skills-page.tsx
@@ -0,0 +1,160 @@
+import { IconLoader2, IconPlus } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import { PageHeader } from "@/components/page-header"
+import { Button } from "@/components/ui/button"
+
+import { DeleteDialog } from "./delete-dialog"
+import { DetailSheet } from "./detail-sheet"
+import { FilterBar } from "./filter-bar"
+import { ImportDialog } from "./import-dialog"
+import { PageSkeleton } from "./page-skeleton"
+import { SkillsList } from "./skills-list"
+import { Stats } from "./stats"
+import { useSkillsPage } from "./use-skills-page"
+
+export function SkillsPage() {
+ const { t } = useTranslation()
+ const {
+ searchQuery,
+ sourceFilter,
+ sortOrder,
+ layoutMode,
+ detailView,
+ isDragActive,
+ isImportDialogOpen,
+ selectedSkill,
+ skillPendingDelete,
+ availableOrigins,
+ groupedSkills,
+ stats,
+ sortedSkills,
+ hasActiveFilters,
+ importInputRef,
+ selectedSkillDetail,
+ skillsError,
+ skillDetailError,
+ isLoading,
+ isSkillDetailLoading,
+ isImportPending,
+ isDeletePending,
+ setSearchQuery,
+ setSourceFilter,
+ setSortOrder,
+ setLayoutMode,
+ setDetailView,
+ openImportDialog,
+ handleViewSkill,
+ handleRequestDelete,
+ handleConfirmDelete,
+ handleImportClick,
+ handleImportFileChange,
+ handleDropZoneDragEnter,
+ handleDropZoneDragLeave,
+ handleDropZoneDrop,
+ handleDetailSheetOpenChange,
+ handleImportDialogOpenChange,
+ handleDeleteDialogOpenChange,
+ } = useSkillsPage()
+
+ return (
+
+
+
+
+ >
+ }
+ />
+
+
+
+ {isLoading ? (
+
+ ) : skillsError ? (
+
+ {t("pages.agent.load_error")}
+
+ ) : (
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/stats.tsx b/web/frontend/src/components/agent/skills/stats.tsx
new file mode 100644
index 000000000..c718fc3be
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/stats.tsx
@@ -0,0 +1,39 @@
+import { Card, CardContent } from "@/components/ui/card"
+import { cn } from "@/lib/utils"
+
+import { OriginIcon } from "./origin-badge"
+import { getOriginAccentClasses } from "./origin-utils"
+import type { SkillStatItem } from "./types"
+
+export function Stats({ stats }: { stats: SkillStatItem[] }) {
+ return (
+
+ {stats.map((stat) => (
+
+
+
+
+ {stat.label}
+
+
+ {stat.count}
+
+
+
+
+
+
+
+ ))}
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/types.ts b/web/frontend/src/components/agent/skills/types.ts
new file mode 100644
index 000000000..44509854c
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/types.ts
@@ -0,0 +1,17 @@
+import type { SkillSupportItem } from "@/api/skills"
+
+export type SkillSortOption = "name-asc" | "name-desc" | "source"
+export type SkillLayoutMode = "grouped" | "grid"
+export type SkillDetailView = "preview" | "raw" | "meta"
+
+export interface SkillGroupSection {
+ origin: string
+ skills: SkillSupportItem[]
+}
+
+export interface SkillStatItem {
+ key: string
+ origin: string
+ label: string
+ count: number
+}
diff --git a/web/frontend/src/components/agent/skills/use-skills-page.ts b/web/frontend/src/components/agent/skills/use-skills-page.ts
new file mode 100644
index 000000000..7cf4a01ad
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/use-skills-page.ts
@@ -0,0 +1,339 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import {
+ type ChangeEvent,
+ type DragEvent,
+ startTransition,
+ useDeferredValue,
+ useMemo,
+ useRef,
+ useState,
+} from "react"
+import { useTranslation } from "react-i18next"
+import { toast } from "sonner"
+
+import {
+ type SkillSupportItem,
+ deleteSkill,
+ getSkill,
+ getSkills,
+ importSkill,
+} from "@/api/skills"
+
+import {
+ compareSkills,
+ getOriginLabel,
+ getSkillOriginKind,
+ sortOrigins,
+} from "./origin-utils"
+import type {
+ SkillDetailView,
+ SkillGroupSection,
+ SkillLayoutMode,
+ SkillSortOption,
+ SkillStatItem,
+} from "./types"
+
+const MAX_IMPORT_FILE_SIZE = 1 << 20
+
+export function useSkillsPage() {
+ const { t } = useTranslation()
+ const queryClient = useQueryClient()
+ const importInputRef = useRef(null)
+ const dragDepthRef = useRef(0)
+
+ const [searchQuery, setSearchQuery] = useState("")
+ const deferredSearchQuery = useDeferredValue(searchQuery)
+ const [sourceFilter, setSourceFilter] = useState("all")
+ const [sortOrder, setSortOrder] = useState("name-asc")
+ const [layoutMode, setLayoutMode] = useState("grouped")
+ const [detailView, setDetailView] = useState("preview")
+ const [isDragActive, setIsDragActive] = useState(false)
+ const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
+ const [selectedSkill, setSelectedSkill] = useState(
+ null,
+ )
+ const [skillPendingDelete, setSkillPendingDelete] =
+ useState(null)
+
+ const skillsQuery = useQuery({
+ queryKey: ["skills"],
+ queryFn: getSkills,
+ })
+
+ const skillDetailQuery = useQuery({
+ queryKey: ["skills", selectedSkill?.name],
+ queryFn: () => getSkill(selectedSkill!.name),
+ enabled: selectedSkill !== null,
+ })
+
+ const importMutation = useMutation({
+ mutationFn: async (file: File) => importSkill(file),
+ onSuccess: (importedSkill) => {
+ toast.success(t("pages.agent.skills.import_success"))
+ startTransition(() => {
+ setIsImportDialogOpen(false)
+ setDetailView("preview")
+ if (importedSkill.name) {
+ setSelectedSkill({
+ name: importedSkill.name,
+ path: importedSkill.path ?? "",
+ source: importedSkill.source ?? "workspace",
+ description: importedSkill.description ?? "",
+ origin_kind: importedSkill.origin_kind ?? "manual",
+ registry_name: importedSkill.registry_name,
+ registry_url: importedSkill.registry_url,
+ installed_version: importedSkill.installed_version,
+ installed_at: importedSkill.installed_at,
+ })
+ }
+ })
+ void queryClient.invalidateQueries({ queryKey: ["skills"] })
+ },
+ onError: (err) => {
+ toast.error(
+ err instanceof Error
+ ? err.message
+ : t("pages.agent.skills.import_error"),
+ )
+ },
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: async (name: string) => deleteSkill(name),
+ onSuccess: (_, deletedName) => {
+ toast.success(t("pages.agent.skills.delete_success"))
+ setSkillPendingDelete(null)
+ if (
+ selectedSkill?.name === deletedName &&
+ selectedSkill.source === "workspace"
+ ) {
+ setSelectedSkill(null)
+ }
+ void queryClient.invalidateQueries({ queryKey: ["skills"] })
+ },
+ onError: (err) => {
+ toast.error(
+ err instanceof Error
+ ? err.message
+ : t("pages.agent.skills.delete_error"),
+ )
+ },
+ })
+
+ const allSkills = useMemo(
+ () => skillsQuery.data?.skills ?? [],
+ [skillsQuery.data?.skills],
+ )
+ const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase()
+
+ const availableOrigins = useMemo(
+ () =>
+ sortOrigins([
+ ...new Set(allSkills.map((skill) => getSkillOriginKind(skill))),
+ ]),
+ [allSkills],
+ )
+
+ const filteredSkills = useMemo(() => {
+ return allSkills.filter((skill) => {
+ const matchesSource =
+ sourceFilter === "all"
+ ? true
+ : getSkillOriginKind(skill) === sourceFilter
+ if (!matchesSource) return false
+ if (normalizedSearchQuery === "") return true
+
+ const searchTarget =
+ `${skill.name} ${skill.description} ${skill.registry_name ?? ""}`.toLowerCase()
+ return searchTarget.includes(normalizedSearchQuery)
+ })
+ }, [allSkills, normalizedSearchQuery, sourceFilter])
+
+ const sortedSkills = useMemo(
+ () =>
+ [...filteredSkills].sort((left, right) =>
+ compareSkills(left, right, sortOrder),
+ ),
+ [filteredSkills, sortOrder],
+ )
+
+ const groupedSkills = useMemo(
+ () =>
+ availableOrigins
+ .map((origin) => ({
+ origin,
+ skills: sortedSkills.filter(
+ (skill) => getSkillOriginKind(skill) === origin,
+ ),
+ }))
+ .filter((section) => section.skills.length > 0),
+ [availableOrigins, sortedSkills],
+ )
+
+ const stats = useMemo(
+ () => [
+ {
+ key: "all",
+ origin: "all",
+ label: t("pages.agent.skills.summary.total"),
+ count: allSkills.length,
+ },
+ ...availableOrigins.map((origin) => ({
+ key: origin,
+ origin,
+ label: getOriginLabel(origin, t),
+ count: allSkills.filter((skill) => getSkillOriginKind(skill) === origin)
+ .length,
+ })),
+ ],
+ [allSkills, availableOrigins, t],
+ )
+
+ const hasActiveFilters =
+ normalizedSearchQuery !== "" || sourceFilter !== "all"
+
+ const handleImportClick = () => {
+ importInputRef.current?.click()
+ }
+
+ const handleViewSkill = (skill: SkillSupportItem) => {
+ setDetailView("preview")
+ setSelectedSkill(skill)
+ }
+
+ const handleRequestDelete = (skill: SkillSupportItem) => {
+ setSkillPendingDelete(skill)
+ }
+
+ const handleConfirmDelete = () => {
+ if (skillPendingDelete) {
+ deleteMutation.mutate(skillPendingDelete.name)
+ }
+ }
+
+ const handleDetailSheetOpenChange = (open: boolean) => {
+ if (!open) {
+ setSelectedSkill(null)
+ }
+ }
+
+ const handleImportDialogOpenChange = (open: boolean) => {
+ if (!importMutation.isPending) {
+ setIsImportDialogOpen(open)
+ }
+ }
+
+ const handleDeleteDialogOpenChange = (open: boolean) => {
+ if (!open) {
+ setSkillPendingDelete(null)
+ }
+ }
+
+ const validateImportFile = (file: File) => {
+ const fileName = file.name.toLowerCase()
+ const isMarkdownFile =
+ fileName.endsWith(".md") ||
+ file.type === "text/markdown" ||
+ file.type === "text/plain" ||
+ file.type === ""
+ const isZipFile =
+ fileName.endsWith(".zip") ||
+ file.type === "application/zip" ||
+ file.type === "application/x-zip-compressed"
+
+ if (!isMarkdownFile && !isZipFile) {
+ return t("pages.agent.skills.import_invalid_type")
+ }
+
+ if (file.size > MAX_IMPORT_FILE_SIZE) {
+ return t("pages.agent.skills.import_invalid_size")
+ }
+
+ return null
+ }
+
+ const handleImportFile = (file: File) => {
+ const validationMessage = validateImportFile(file)
+ if (validationMessage) {
+ toast.error(validationMessage)
+ return
+ }
+ importMutation.mutate(file)
+ }
+
+ const handleImportFileChange = (event: ChangeEvent) => {
+ const file = event.target.files?.[0]
+ if (!file) return
+ handleImportFile(file)
+ event.target.value = ""
+ }
+
+ const resetDragState = () => {
+ dragDepthRef.current = 0
+ setIsDragActive(false)
+ }
+
+ const handleDropZoneDragEnter = (event: DragEvent) => {
+ event.preventDefault()
+ dragDepthRef.current += 1
+ setIsDragActive(true)
+ }
+
+ const handleDropZoneDragLeave = (event: DragEvent) => {
+ event.preventDefault()
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
+ if (dragDepthRef.current === 0) {
+ setIsDragActive(false)
+ }
+ }
+
+ const handleDropZoneDrop = (event: DragEvent) => {
+ event.preventDefault()
+ const file = event.dataTransfer.files?.[0]
+ resetDragState()
+ if (!file) return
+ handleImportFile(file)
+ }
+
+ return {
+ searchQuery,
+ sourceFilter,
+ sortOrder,
+ layoutMode,
+ detailView,
+ isDragActive,
+ isImportDialogOpen,
+ selectedSkill,
+ skillPendingDelete,
+ availableOrigins,
+ groupedSkills,
+ stats,
+ sortedSkills,
+ hasActiveFilters,
+ importInputRef,
+ selectedSkillDetail: skillDetailQuery.data,
+ skillsError: skillsQuery.error,
+ skillDetailError: skillDetailQuery.error,
+ isLoading: skillsQuery.isLoading,
+ isSkillDetailLoading: skillDetailQuery.isLoading,
+ isImportPending: importMutation.isPending,
+ isDeletePending: deleteMutation.isPending,
+ setSearchQuery,
+ setSourceFilter,
+ setSortOrder,
+ setLayoutMode,
+ setDetailView,
+ openImportDialog: () => setIsImportDialogOpen(true),
+ handleViewSkill,
+ handleRequestDelete,
+ handleConfirmDelete,
+ handleImportClick,
+ handleImportFileChange,
+ handleDropZoneDragEnter,
+ handleDropZoneDragLeave,
+ handleDropZoneDrop,
+ handleDetailSheetOpenChange,
+ handleImportDialogOpenChange,
+ handleDeleteDialogOpenChange,
+ }
+}
diff --git a/web/frontend/src/components/agent/tools/tool-library-tab.tsx b/web/frontend/src/components/agent/tools/tool-library-tab.tsx
new file mode 100644
index 000000000..6bbfeb091
--- /dev/null
+++ b/web/frontend/src/components/agent/tools/tool-library-tab.tsx
@@ -0,0 +1,270 @@
+import { IconSearch, IconSettings } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import type { ToolSupportItem } from "@/api/tools"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Switch } from "@/components/ui/switch"
+import { cn } from "@/lib/utils"
+
+import { ToolStatusBadge } from "./tool-status-badge"
+import type { GroupedTools, ToolStatusFilter } from "./types"
+
+interface ToolLibraryTabProps {
+ allTools: ToolSupportItem[]
+ groupedTools: GroupedTools
+ totalFilteredCount: number
+ searchQuery: string
+ statusFilter: ToolStatusFilter
+ isLoading: boolean
+ hasError: boolean
+ pendingToolName: string | null
+ onSearchQueryChange: (value: string) => void
+ onStatusFilterChange: (value: ToolStatusFilter) => void
+ onOpenWebSearchSettings: () => void
+ onToggleTool: (name: string, enabled: boolean) => void
+}
+
+export function ToolLibraryTab({
+ allTools,
+ groupedTools,
+ totalFilteredCount,
+ searchQuery,
+ statusFilter,
+ isLoading,
+ hasError,
+ pendingToolName,
+ onSearchQueryChange,
+ onStatusFilterChange,
+ onOpenWebSearchSettings,
+ onToggleTool,
+}: ToolLibraryTabProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
)
+ for _, msg := range result.Messages {
+ if strings.Contains(msg.Content, "= 5
+ // This tests the bug: when depth=2 is missing, the loop breaks and depth=3 is never checked
+ // Need > FreshTailCount(32) summaries so they are not all in fresh tail
+ // Depth 0: 3 summaries (not enough), Depth 1: 3 summaries (not enough)
+ // Depth 2: 0 summaries (missing), Depth 3: 40 summaries (enough)
+ depths := []int{0, 0, 0, 1, 1, 1}
+ for i := 0; i < 40; i++ {
+ depths = append(depths, 3)
+ }
+ now := time.Now().UTC()
+
+ for i, depth := range depths {
+ sum, createErr := e.store.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: depth,
+ Content: fmt.Sprintf("summary depth %d #%d", depth, i),
+ TokenCount: 10,
+ EarliestAt: &now,
+ LatestAt: &now,
+ })
+ if createErr != nil {
+ t.Fatalf("CreateSummary: %v", createErr)
+ }
+ // Add to context items (not in fresh tail)
+ if appendErr := e.store.AppendContextSummary(ctx, conv.ConversationID, sum.SummaryID); appendErr != nil {
+ t.Fatalf("AppendContextSummary: %v", appendErr)
+ }
+ }
+
+ // Initialize compaction engine (lazy init)
+ e.initCompactionOnce()
+
+ // Call selectShallowestCondensationCandidate
+ candidates, err := e.compaction.selectShallowestCondensationCandidate(ctx, conv.ConversationID, false)
+ if err != nil {
+ t.Fatalf("selectShallowestCondensationCandidate: %v", err)
+ }
+
+ // Should find depth=0 (shallowest) with 5 summaries
+ if candidates == nil {
+ t.Fatal("expected candidates, got nil")
+ }
+ if len(candidates) < CondensedMinFanout {
+ t.Errorf("expected at least %d candidates, got %d", CondensedMinFanout, len(candidates))
+ }
+
+ // Verify all returned summaries have the same depth
+ if len(candidates) > 0 {
+ expectedDepth := candidates[0].Depth
+ for _, c := range candidates[1:] {
+ if c.Depth != expectedDepth {
+ t.Errorf("candidates have mixed depths: %d vs %d", expectedDepth, c.Depth)
+ }
+ }
+ }
+}
diff --git a/pkg/seahorse/short_retrieval.go b/pkg/seahorse/short_retrieval.go
new file mode 100644
index 000000000..3e94eec14
--- /dev/null
+++ b/pkg/seahorse/short_retrieval.go
@@ -0,0 +1,212 @@
+package seahorse
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// ParseLastDuration parses a "last" duration string like "6h", "7d", "2w", "1m".
+// Returns the duration and nil error, or zero and error if invalid.
+func ParseLastDuration(s string) (time.Duration, error) {
+ if s == "" {
+ return 0, fmt.Errorf("empty duration")
+ }
+
+ re := regexp.MustCompile(`^(\d+)([hdwm])$`)
+ matches := re.FindStringSubmatch(s)
+ if matches == nil {
+ return 0, fmt.Errorf("invalid duration format: %q (use format like 6h, 7d, 2w, 1m)", s)
+ }
+
+ value, _ := strconv.Atoi(matches[1])
+ unit := matches[2]
+
+ switch unit {
+ case "h":
+ return time.Duration(value) * time.Hour, nil
+ case "d":
+ return time.Duration(value) * 24 * time.Hour, nil
+ case "w":
+ return time.Duration(value) * 7 * 24 * time.Hour, nil
+ case "m":
+ return time.Duration(value) * 30 * 24 * time.Hour, nil
+ default:
+ return 0, fmt.Errorf("unknown unit: %q", unit)
+ }
+}
+
+// GrepInput controls search across summaries and messages.
+type GrepInput struct {
+ Pattern string `json:"pattern"`
+ Scope string `json:"scope,omitempty"` // "both" (default), "summary", or "message"
+ Role string `json:"role,omitempty"` // "user", "assistant", or "" (all)
+ AllConversations bool `json:"allConversations,omitempty"`
+ Since *time.Time `json:"since,omitempty"`
+ Before *time.Time `json:"before,omitempty"`
+ Last string `json:"last,omitempty"` // shortcut: "6h", "7d", "2w", "1m"
+ Limit int `json:"limit,omitempty"`
+}
+
+// GrepResult contains search results.
+type GrepResult struct {
+ Success bool `json:"success"`
+ Summaries []GrepSummaryResult `json:"summaries"`
+ Messages []GrepMessageResult `json:"messages"`
+ TotalSummaries int `json:"totalSummaries"`
+ TotalMessages int `json:"totalMessages"`
+ Hint string `json:"hint,omitempty"`
+}
+
+// GrepSummaryResult is a summary match from grep.
+type GrepSummaryResult struct {
+ ID string `json:"id"`
+ Content string `json:"content"`
+ Depth int `json:"depth"`
+ Kind SummaryKind `json:"kind"`
+ ConversationID int64 `json:"conversationId"`
+ // Rank is the bm25 relevance score (negative value, lower = better match).
+ // Examples: -5.0 = excellent match, -2.0 = good match, -0.5 = partial match.
+ Rank float64 `json:"rank,omitempty"`
+}
+
+// GrepMessageResult is a message match from grep.
+type GrepMessageResult struct {
+ ID int64 `json:"id,string"`
+ Snippet string `json:"snippet"`
+ Role string `json:"role"`
+ ConversationID int64 `json:"conversationId"`
+ Rank float64 `json:"rank,omitempty"` // Relevance score (more negative = better match)
+}
+
+// ExpandMessagesResult contains expanded messages.
+type ExpandMessagesResult struct {
+ Messages []Message `json:"messages"`
+ TokenCount int `json:"tokenCount"`
+}
+
+// Grep searches summaries and messages for matching content.
+func (r *RetrievalEngine) Grep(ctx context.Context, input GrepInput) (*GrepResult, error) {
+ if input.Pattern == "" {
+ return nil, fmt.Errorf("grep: pattern is required")
+ }
+
+ limit := input.Limit
+ if limit == 0 {
+ limit = 20
+ }
+
+ // Handle Last parameter: convert to Since
+ since := input.Since
+ if input.Last != "" {
+ dur, err := ParseLastDuration(input.Last)
+ if err != nil {
+ return nil, fmt.Errorf("grep: invalid last: %w", err)
+ }
+ t := time.Now().UTC().Add(-dur)
+ since = &t
+ }
+
+ // Auto-detect mode: use LIKE if pattern contains %, otherwise full-text
+ mode := ""
+ if strings.Contains(input.Pattern, "%") {
+ mode = "like"
+ }
+
+ searchInput := SearchInput{
+ Pattern: input.Pattern,
+ Mode: mode,
+ Role: input.Role,
+ AllConversations: input.AllConversations,
+ Since: since,
+ Before: input.Before,
+ Limit: limit,
+ }
+
+ result := &GrepResult{
+ Success: true,
+ Summaries: make([]GrepSummaryResult, 0),
+ Messages: make([]GrepMessageResult, 0),
+ TotalSummaries: 0,
+ TotalMessages: 0,
+ }
+
+ // Determine scope
+ scope := input.Scope
+ if scope == "" {
+ scope = "both"
+ }
+
+ // Search summaries if requested
+ if scope == "both" || scope == "summary" {
+ sumResults, err := r.store.SearchSummaries(ctx, searchInput)
+ if err != nil {
+ return nil, fmt.Errorf("search summaries: %w", err)
+ }
+ for _, sr := range sumResults {
+ if sr.SummaryID != "" {
+ result.Summaries = append(result.Summaries, GrepSummaryResult{
+ ID: sr.SummaryID,
+ Content: sr.Content,
+ Depth: sr.Depth,
+ Kind: sr.Kind,
+ ConversationID: sr.ConversationID,
+ Rank: sr.Rank,
+ })
+ }
+ }
+ if len(sumResults) > 0 {
+ result.TotalSummaries = sumResults[0].TotalCount
+ }
+ }
+
+ // Search messages if requested
+ if scope == "both" || scope == "message" {
+ msgResults, err := r.store.SearchMessages(ctx, searchInput)
+ if err != nil {
+ return nil, fmt.Errorf("search messages: %w", err)
+ }
+ for _, sr := range msgResults {
+ if sr.MessageID > 0 {
+ result.Messages = append(result.Messages, GrepMessageResult{
+ ID: sr.MessageID,
+ Snippet: sr.Snippet,
+ Role: sr.Role,
+ ConversationID: sr.ConversationID,
+ Rank: sr.Rank,
+ })
+ }
+ }
+ if len(msgResults) > 0 {
+ result.TotalMessages = msgResults[0].TotalCount
+ }
+ }
+
+ // Add hint if no results
+ if len(result.Summaries) == 0 && len(result.Messages) == 0 {
+ result.Hint = "No matches. Try: %keyword% for fuzzy search, or all_conversations: true"
+ }
+
+ return result, nil
+}
+
+// ExpandMessages retrieves full message content by IDs.
+func (r *RetrievalEngine) ExpandMessages(ctx context.Context, messageIDs []int64) (*ExpandMessagesResult, error) {
+ result := &ExpandMessagesResult{
+ Messages: make([]Message, 0, len(messageIDs)),
+ }
+
+ for _, msgID := range messageIDs {
+ msg, err := r.store.GetMessageByID(ctx, msgID)
+ if err != nil {
+ continue
+ }
+ result.Messages = append(result.Messages, *msg)
+ result.TokenCount += msg.TokenCount
+ }
+
+ return result, nil
+}
diff --git a/pkg/seahorse/short_retrieval_test.go b/pkg/seahorse/short_retrieval_test.go
new file mode 100644
index 000000000..9d9bc3640
--- /dev/null
+++ b/pkg/seahorse/short_retrieval_test.go
@@ -0,0 +1,362 @@
+package seahorse
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+)
+
+// --- Retrieval Tests ---
+
+func newTestRetrieval(t *testing.T) (*RetrievalEngine, *Store, int64) {
+ t.Helper()
+ s := openTestStore(t)
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "test:retrieval")
+ return &RetrievalEngine{store: s}, s, conv.ConversationID
+}
+
+func TestRetrievalGrepSummaries(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "数据库连接配置说明",
+ TokenCount: 50,
+ })
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "API endpoint documentation",
+ TokenCount: 50,
+ })
+
+ // FTS5 search (trigram, needs >= 3 chars)
+ results, err := r.Grep(ctx, GrepInput{
+ Pattern: "数据库连",
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ if len(results.Summaries) == 0 {
+ t.Error("expected at least 1 FTS result")
+ }
+
+ // LIKE search with wildcard
+ results, err = r.Grep(ctx, GrepInput{
+ Pattern: "%endpoint%",
+ })
+ if err != nil {
+ t.Fatalf("Grep LIKE: %v", err)
+ }
+ if len(results.Summaries) == 0 {
+ t.Error("expected at least 1 LIKE result")
+ }
+}
+
+func TestRetrievalGrepMessages(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ s.AddMessage(ctx, convID, "user", "find this message about testing", 5)
+ s.AddMessage(ctx, convID, "user", "unrelated content here", 5)
+
+ results, err := r.Grep(ctx, GrepInput{
+ Pattern: "testing",
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ if len(results.Messages) == 0 {
+ t.Error("expected at least 1 result for 'testing'")
+ }
+}
+
+func TestRetrievalExpandMessages(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ msg, _ := s.AddMessage(ctx, convID, "user", "expand this message", 10)
+
+ result, err := r.ExpandMessages(ctx, []int64{msg.ID})
+ if err != nil {
+ t.Fatalf("ExpandMessages: %v", err)
+ }
+ if len(result.Messages) != 1 {
+ t.Errorf("Messages = %d, want 1", len(result.Messages))
+ }
+ if result.Messages[0].Content != "expand this message" {
+ t.Errorf("Content = %q, want 'expand this message'", result.Messages[0].Content)
+ }
+}
+
+func TestRetrievalExpandMultipleMessages(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ msg1, _ := s.AddMessage(ctx, convID, "user", "first message", 10)
+ msg2, _ := s.AddMessage(ctx, convID, "assistant", "second message", 10)
+ msg3, _ := s.AddMessage(ctx, convID, "user", "third message", 10)
+
+ result, err := r.ExpandMessages(ctx, []int64{msg1.ID, msg2.ID, msg3.ID})
+ if err != nil {
+ t.Fatalf("ExpandMessages: %v", err)
+ }
+ if len(result.Messages) != 3 {
+ t.Errorf("Messages = %d, want 3", len(result.Messages))
+ }
+ if result.TokenCount != 30 {
+ t.Errorf("TokenCount = %d, want 30", result.TokenCount)
+ }
+}
+
+func TestRetrievalGrepWithTimeFilter(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ now := time.Now().UTC()
+ before := now.Add(-2 * time.Hour)
+
+ // Create messages at different times
+ s.AddMessage(ctx, convID, "user", "old message about auth", 5)
+ s.AddMessage(ctx, convID, "user", "recent message about auth", 5)
+
+ // Search with time filter
+ results, err := r.Grep(ctx, GrepInput{
+ Pattern: "auth",
+ Since: &before,
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ _ = results // Just verify no error
+}
+
+func TestRetrievalGrepAllConversations(t *testing.T) {
+ r, s, _ := newTestRetrieval(t)
+ ctx := context.Background()
+
+ // Create another conversation
+ conv2, _ := s.GetOrCreateConversation(ctx, "test:retrieval2")
+
+ // Add messages to both
+ s.AddMessage(ctx, conv2.ConversationID, "user", "unique keyword xyz", 5)
+
+ // Search all conversations
+ results, err := r.Grep(ctx, GrepInput{
+ Pattern: "xyz",
+ AllConversations: true,
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ if len(results.Messages) == 0 {
+ t.Error("expected to find message in other conversation")
+ }
+}
+
+// --- Last Duration Parsing Tests ---
+
+func TestParseLastDuration(t *testing.T) {
+ tests := []struct {
+ input string
+ wantDur time.Duration
+ wantErr bool
+ }{
+ {"6h", 6 * time.Hour, false},
+ {"1d", 24 * time.Hour, false},
+ {"7d", 7 * 24 * time.Hour, false},
+ {"2w", 14 * 24 * time.Hour, false},
+ {"1m", 30 * 24 * time.Hour, false}, // month = 30 days
+ {"3m", 90 * 24 * time.Hour, false},
+ {"", 0, true},
+ {"invalid", 0, true},
+ {"5x", 0, true}, // unknown unit
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got, err := ParseLastDuration(tt.input)
+ if tt.wantErr {
+ if err == nil {
+ t.Error("expected error, got nil")
+ }
+ } else {
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != tt.wantDur {
+ t.Errorf("ParseLastDuration(%q) = %v, want %v", tt.input, got, tt.wantDur)
+ }
+ }
+ })
+ }
+}
+
+// --- Role Filter Tests ---
+
+func TestRetrievalGrepRoleFilter(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ s.AddMessage(ctx, convID, "user", "user message about alpha", 5)
+ s.AddMessage(ctx, convID, "assistant", "assistant reply about alpha", 5)
+ s.AddMessage(ctx, convID, "user", "another user message", 5)
+
+ // Search all roles
+ allResults, err := r.Grep(ctx, GrepInput{
+ Pattern: "alpha",
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ if len(allResults.Messages) != 2 {
+ t.Errorf("expected 2 messages, got %d", len(allResults.Messages))
+ }
+
+ // Search user only
+ userResults, err := r.Grep(ctx, GrepInput{
+ Pattern: "alpha",
+ Role: "user",
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ if len(userResults.Messages) != 1 {
+ t.Errorf("expected 1 user message, got %d", len(userResults.Messages))
+ }
+ if userResults.Messages[0].Role != "user" {
+ t.Errorf("expected role=user, got %s", userResults.Messages[0].Role)
+ }
+
+ // Search assistant only
+ assistantResults, err := r.Grep(ctx, GrepInput{
+ Pattern: "alpha",
+ Role: "assistant",
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ if len(assistantResults.Messages) != 1 {
+ t.Errorf("expected 1 assistant message, got %d", len(assistantResults.Messages))
+ }
+}
+
+// --- Last Parameter Tests ---
+
+func TestRetrievalGrepWithLast(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ // Add messages (we can't control timestamps in SQLite easily,
+ // but we can verify the parameter is parsed correctly)
+ s.AddMessage(ctx, convID, "user", "recent message about testing", 5)
+
+ // Test that Last parameter is converted to Since
+ results, err := r.Grep(ctx, GrepInput{
+ Pattern: "testing",
+ Last: "1d", // last 1 day
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ // Should still find the message since it's recent
+ if len(results.Messages) == 0 {
+ t.Error("expected to find recent message")
+ }
+}
+
+// TestRetrievalGrepRoleFilterWithSummaries tests that role filter works when
+// searching both summaries and messages (summaries don't have role column).
+func TestRetrievalGrepRoleFilterWithSummaries(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ // Create a summary (no role column)
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "summary about testing",
+ TokenCount: 50,
+ })
+
+ // Add messages with different roles
+ s.AddMessage(ctx, convID, "user", "user message about testing", 5)
+ s.AddMessage(ctx, convID, "assistant", "assistant reply about testing", 5)
+
+ // Search with role filter and scope=both (default), using LIKE mode (%)
+ // This should NOT error even though summaries don't have role column
+ bothResults, err := r.Grep(ctx, GrepInput{
+ Pattern: "%testing%", // LIKE mode to trigger the bug
+ Role: "user",
+ Scope: "both",
+ })
+ if err != nil {
+ t.Fatalf("Grep with role and scope=both: %v", err)
+ }
+
+ // Should only return user messages, not summaries or assistant messages
+ if len(bothResults.Messages) != 1 {
+ t.Errorf("expected 1 user message, got %d", len(bothResults.Messages))
+ }
+ if len(bothResults.Messages) > 0 && bothResults.Messages[0].Role != "user" {
+ t.Errorf("expected role=user, got %s", bothResults.Messages[0].Role)
+ }
+
+ // Summaries should be empty since they don't have roles to filter
+ // (or we could return all summaries - either is acceptable)
+}
+
+// TestRetrievalGrepTotalCounts tests that grep returns total counts.
+func TestRetrievalGrepTotalCounts(t *testing.T) {
+ r, s, convID := newTestRetrieval(t)
+ ctx := context.Background()
+
+ // Create 3 summaries
+ for i := 0; i < 3; i++ {
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: convID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: fmt.Sprintf("summary about testing %d", i),
+ TokenCount: 50,
+ })
+ }
+
+ // Add 5 messages
+ for i := 0; i < 5; i++ {
+ s.AddMessage(ctx, convID, "user", fmt.Sprintf("message about testing %d", i), 5)
+ }
+
+ // Search with limit smaller than total
+ results, err := r.Grep(ctx, GrepInput{
+ Pattern: "%testing%", // LIKE mode
+ Scope: "both",
+ Limit: 2,
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+
+ // Should return limited results
+ if len(results.Summaries) > 2 {
+ t.Errorf("expected at most 2 summaries, got %d", len(results.Summaries))
+ }
+ if len(results.Messages) > 2 {
+ t.Errorf("expected at most 2 messages, got %d", len(results.Messages))
+ }
+
+ // But total counts should reflect all matches
+ if results.TotalSummaries != 3 {
+ t.Errorf("expected TotalSummaries=3, got %d", results.TotalSummaries)
+ }
+ if results.TotalMessages != 5 {
+ t.Errorf("expected TotalMessages=5, got %d", results.TotalMessages)
+ }
+}
diff --git a/pkg/seahorse/store.go b/pkg/seahorse/store.go
new file mode 100644
index 000000000..0edbbd128
--- /dev/null
+++ b/pkg/seahorse/store.go
@@ -0,0 +1,1642 @@
+package seahorse
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// Store provides SQLite storage for seahorse.
+type Store struct {
+ db *sql.DB
+}
+
+// CreateSummaryInput holds parameters for creating a summary.
+type CreateSummaryInput struct {
+ ConversationID int64
+ Kind SummaryKind
+ Depth int
+ Content string
+ TokenCount int
+ EarliestAt *time.Time
+ LatestAt *time.Time
+ DescendantCount int
+ DescendantTokenCount int
+ SourceMessageTokens int
+ Model string
+ ParentIDs []string // For condensed: child summary IDs being condensed
+}
+
+// --- Conversation Operations ---
+
+// GetOrCreateConversation returns the conversation for a sessionKey, creating if needed.
+func (s *Store) GetOrCreateConversation(ctx context.Context, sessionKey string) (*Conversation, error) {
+ // Try to get first
+ conv, err := s.GetConversationBySessionKey(ctx, sessionKey)
+ if err != nil {
+ return nil, err
+ }
+ if conv != nil {
+ return conv, nil
+ }
+
+ // Create
+ result, err := s.db.ExecContext(ctx,
+ "INSERT INTO conversations (session_key) VALUES (?)",
+ sessionKey,
+ )
+ if err != nil {
+ // Race: another goroutine may have inserted
+ if isUniqueViolation(err) {
+ return s.GetConversationBySessionKey(ctx, sessionKey)
+ }
+ return nil, fmt.Errorf("create conversation: %w", err)
+ }
+ id, _ := result.LastInsertId()
+ return &Conversation{
+ ConversationID: id,
+ SessionKey: sessionKey,
+ }, nil
+}
+
+// GetConversationBySessionKey retrieves a conversation by session key.
+func (s *Store) GetConversationBySessionKey(ctx context.Context, sessionKey string) (*Conversation, error) {
+ var conv Conversation
+ var createdAt, updatedAt string
+ err := s.db.QueryRowContext(ctx,
+ "SELECT conversation_id, session_key, created_at, updated_at FROM conversations WHERE session_key = ?",
+ sessionKey,
+ ).Scan(&conv.ConversationID, &conv.SessionKey, &createdAt, &updatedAt)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("get conversation by session key: %w", err)
+ }
+ conv.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ conv.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
+ return &conv, nil
+}
+
+// GetSessionStatus returns status for a specific session.
+func (s *Store) GetSessionStatus(ctx context.Context, sessionKey string) (*SessionStatus, error) {
+ conv, err := s.GetConversationBySessionKey(ctx, sessionKey)
+ if err != nil {
+ return nil, err
+ }
+ if conv == nil {
+ return nil, nil
+ }
+
+ msgCount, _ := s.GetMessageCount(ctx, conv.ConversationID)
+ sumCount, _ := s.getSummaryCount(ctx, conv.ConversationID)
+ tokenCount, _ := s.GetContextTokenCount(ctx, conv.ConversationID)
+
+ oldest, newest, _ := s.getMessageTimeRange(ctx, conv.ConversationID)
+
+ return &SessionStatus{
+ SessionKey: conv.SessionKey,
+ ConversationID: conv.ConversationID,
+ Messages: msgCount,
+ TotalTokens: tokenCount,
+ Summaries: sumCount,
+ OldestAt: oldest,
+ NewestAt: newest,
+ }, nil
+}
+
+// GetAllSessionStatuses returns status for all sessions.
+func (s *Store) GetAllSessionStatuses(ctx context.Context) ([]SessionStatus, error) {
+ rows, err := s.db.QueryContext(ctx, "SELECT session_key FROM conversations")
+ if err != nil {
+ return nil, fmt.Errorf("list sessions: %w", err)
+ }
+ defer rows.Close()
+
+ var statuses []SessionStatus
+ for rows.Next() {
+ var sessionKey string
+ if err := rows.Scan(&sessionKey); err != nil {
+ continue
+ }
+ status, err := s.GetSessionStatus(ctx, sessionKey)
+ if err != nil {
+ continue
+ }
+ if status != nil {
+ statuses = append(statuses, *status)
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate sessions: %w", err)
+ }
+ return statuses, nil
+}
+
+func (s *Store) getSummaryCount(ctx context.Context, convID int64) (int, error) {
+ var count int
+ err := s.db.QueryRowContext(ctx,
+ "SELECT COUNT(*) FROM summaries WHERE conversation_id = ?",
+ convID,
+ ).Scan(&count)
+ return count, err
+}
+
+func (s *Store) getMessageTimeRange(ctx context.Context, convID int64) (time.Time, time.Time, error) {
+ var minTime, maxTime string
+ err := s.db.QueryRowContext(ctx,
+ "SELECT MIN(created_at), MAX(created_at) FROM messages WHERE conversation_id = ?",
+ convID,
+ ).Scan(&minTime, &maxTime)
+ if err != nil || minTime == "" {
+ return time.Time{}, time.Time{}, err
+ }
+ oldest, _ := time.Parse("2006-01-02 15:04:05", minTime)
+ newest, _ := time.Parse("2006-01-02 15:04:05", maxTime)
+ return oldest, newest, nil
+}
+
+// --- Message Operations ---
+
+// AddMessage appends a message to a conversation.
+func (s *Store) AddMessage(ctx context.Context, convID int64, role, content string, tokenCount int) (*Message, error) {
+ return s.AddMessageWithReasoning(ctx, convID, role, content, "", tokenCount)
+}
+
+// AddMessageWithReasoning appends a message with reasoning content to a conversation.
+func (s *Store) AddMessageWithReasoning(
+ ctx context.Context,
+ convID int64,
+ role, content, reasoningContent string,
+ tokenCount int,
+) (*Message, error) {
+ result, err := s.db.ExecContext(ctx,
+ "INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)",
+ convID, role, content, reasoningContent, tokenCount,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("add message: %w", err)
+ }
+ id, _ := result.LastInsertId()
+ return &Message{
+ ID: id,
+ ConversationID: convID,
+ Role: role,
+ Content: content,
+ ReasoningContent: reasoningContent,
+ TokenCount: tokenCount,
+ }, nil
+}
+
+// partsToReadableContent derives a readable text summary from message parts.
+// This ensures FTS5 indexing and summary formatting can access tool call information.
+func partsToReadableContent(parts []MessagePart) string {
+ var b strings.Builder
+ for i, p := range parts {
+ if i > 0 {
+ b.WriteString("\n")
+ }
+ switch p.Type {
+ case "text":
+ b.WriteString(p.Text)
+ case "tool_use":
+ fmt.Fprintf(&b, "[tool_use: %s, args: %s]", p.Name, p.Arguments)
+ case "tool_result":
+ fmt.Fprintf(&b, "[tool_result for %s: %s]", p.ToolCallID, p.Text)
+ case "media":
+ fmt.Fprintf(&b, "[media: %s (%s)]", p.MediaURI, p.MimeType)
+ default:
+ if p.Text != "" {
+ b.WriteString(p.Text)
+ }
+ }
+ }
+ return b.String()
+}
+
+// AddMessageWithParts adds a message with structured parts.
+func (s *Store) AddMessageWithParts(
+ ctx context.Context,
+ convID int64,
+ role string,
+ parts []MessagePart,
+ tokenCount int,
+) (*Message, error) {
+ return s.AddMessageWithPartsAndReasoning(ctx, convID, role, parts, "", tokenCount)
+}
+
+// AddMessageWithPartsAndReasoning adds a message with structured parts and reasoning content.
+func (s *Store) AddMessageWithPartsAndReasoning(
+ ctx context.Context,
+ convID int64,
+ role string,
+ parts []MessagePart,
+ reasoningContent string,
+ tokenCount int,
+) (*Message, error) {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return nil, fmt.Errorf("begin tx: %w", err)
+ }
+ defer tx.Rollback()
+
+ // Derive readable content from Parts for FTS5 indexing and summary formatting
+ readableContent := partsToReadableContent(parts)
+
+ result, err := tx.ExecContext(ctx,
+ "INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)",
+ convID, role, readableContent, reasoningContent, tokenCount,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("add message: %w", err)
+ }
+ msgID, _ := result.LastInsertId()
+
+ for i, p := range parts {
+ _, err = tx.ExecContext(
+ ctx,
+ `INSERT INTO message_parts (message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type, ordinal)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ msgID,
+ p.Type,
+ p.Text,
+ p.Name,
+ p.Arguments,
+ p.ToolCallID,
+ p.MediaURI,
+ p.MimeType,
+ i,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("add message part %d: %w", i, err)
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, fmt.Errorf("commit: %w", err)
+ }
+
+ // Return message with parts
+ msg := &Message{
+ ID: msgID,
+ ConversationID: convID,
+ Role: role,
+ ReasoningContent: reasoningContent,
+ TokenCount: tokenCount,
+ Parts: make([]MessagePart, len(parts)),
+ }
+ for i, p := range parts {
+ p.MessageID = msgID
+ msg.Parts[i] = p
+ }
+ return msg, nil
+}
+
+// GetMessages retrieves messages for a conversation.
+func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, beforeID int64) ([]Message, error) {
+ query := "SELECT message_id, conversation_id, role, content, reasoning_content, token_count, created_at FROM messages WHERE conversation_id = ?"
+ args := []any{convID}
+ if beforeID > 0 {
+ query += " AND message_id < ?"
+ args = append(args, beforeID)
+ }
+ query += " ORDER BY message_id ASC"
+ if limit > 0 {
+ query += " LIMIT ?"
+ args = append(args, limit)
+ }
+
+ rows, err := s.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("get messages: %w", err)
+ }
+ defer rows.Close()
+
+ var msgs []Message
+ for rows.Next() {
+ var msg Message
+ var createdAt string
+ if err := rows.Scan(
+ &msg.ID,
+ &msg.ConversationID,
+ &msg.Role,
+ &msg.Content,
+ &msg.ReasoningContent,
+ &msg.TokenCount,
+ &createdAt,
+ ); err != nil {
+ return nil, err
+ }
+ msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ msgs = append(msgs, msg)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ // Load parts for all messages
+ for i := range msgs {
+ parts, err := s.loadMessageParts(ctx, msgs[i].ID)
+ if err != nil {
+ return nil, err
+ }
+ msgs[i].Parts = parts
+ }
+
+ return msgs, nil
+}
+
+// GetMessageCount returns total message count for a conversation.
+func (s *Store) GetMessageCount(ctx context.Context, convID int64) (int, error) {
+ var count int
+ err := s.db.QueryRowContext(ctx,
+ "SELECT count(*) FROM messages WHERE conversation_id = ?", convID,
+ ).Scan(&count)
+ return count, err
+}
+
+// GetMessageByID retrieves a single message by ID.
+func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message, error) {
+ var msg Message
+ var createdAt string
+ err := s.db.QueryRowContext(
+ ctx,
+ "SELECT message_id, conversation_id, role, content, reasoning_content, token_count, created_at FROM messages WHERE message_id = ?",
+ messageID,
+ ).Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &msg.ReasoningContent, &msg.TokenCount, &createdAt)
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("message %d not found", messageID)
+ }
+ if err != nil {
+ return nil, err
+ }
+ msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ msg.Parts, _ = s.loadMessageParts(ctx, msg.ID)
+ return &msg, nil
+}
+
+// UpdateMessageReasoningContent updates reasoning_content for an existing message.
+func (s *Store) UpdateMessageReasoningContent(ctx context.Context, messageID int64, reasoningContent string) error {
+ result, err := s.db.ExecContext(
+ ctx,
+ "UPDATE messages SET reasoning_content = ? WHERE message_id = ?",
+ reasoningContent,
+ messageID,
+ )
+ if err != nil {
+ return fmt.Errorf("update message reasoning_content: %w", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("update message reasoning_content rows affected: %w", err)
+ }
+ if rowsAffected == 0 {
+ return fmt.Errorf("message %d not found", messageID)
+ }
+ return nil
+}
+
+func (s *Store) loadMessageParts(ctx context.Context, msgID int64) ([]MessagePart, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT part_id, message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type
+ FROM message_parts WHERE message_id = ? ORDER BY ordinal`,
+ msgID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var parts []MessagePart
+ for rows.Next() {
+ var p MessagePart
+ if err := rows.Scan(&p.ID, &p.MessageID, &p.Type, &p.Text, &p.Name, &p.Arguments,
+ &p.ToolCallID, &p.MediaURI, &p.MimeType); err != nil {
+ return nil, err
+ }
+ parts = append(parts, p)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return parts, nil
+}
+
+// --- Summary Operations ---
+
+// CreateSummary creates a new summary and indexes it in FTS5.
+func (s *Store) CreateSummary(ctx context.Context, input CreateSummaryInput) (*Summary, error) {
+ // Generate summary ID
+ now := time.Now().UTC()
+ summaryID := generateSummaryID(input.Content, now)
+
+ var earliestAt, latestAt sql.NullString
+ if input.EarliestAt != nil {
+ earliestAt = sql.NullString{String: input.EarliestAt.Format(time.RFC3339), Valid: true}
+ }
+ if input.LatestAt != nil {
+ latestAt = sql.NullString{String: input.LatestAt.Format(time.RFC3339), Valid: true}
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return nil, fmt.Errorf("begin tx: %w", err)
+ }
+ defer tx.Rollback()
+
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO summaries (summary_id, conversation_id, kind, depth, content, token_count,
+ earliest_at, latest_at, descendant_count, descendant_token_count,
+ source_message_token_count, model)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ summaryID, input.ConversationID, string(input.Kind), input.Depth,
+ input.Content, input.TokenCount,
+ earliestAt, latestAt,
+ input.DescendantCount, input.DescendantTokenCount,
+ input.SourceMessageTokens, input.Model,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("insert summary: %w", err)
+ }
+
+ // FTS trigger will fire automatically for summaries table insert
+
+ // Link parent summaries (DAG edges) for condensed summaries
+ for _, parentID := range input.ParentIDs {
+ _, err = tx.ExecContext(ctx,
+ "INSERT INTO summary_parents (summary_id, parent_summary_id) VALUES (?, ?)",
+ summaryID, parentID,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("link parent %s: %w", parentID, err)
+ }
+ }
+
+ if err := tx.Commit(); err != nil {
+ return nil, fmt.Errorf("commit: %w", err)
+ }
+
+ return &Summary{
+ SummaryID: summaryID,
+ ConversationID: input.ConversationID,
+ Kind: input.Kind,
+ Depth: input.Depth,
+ Content: input.Content,
+ TokenCount: input.TokenCount,
+ EarliestAt: input.EarliestAt,
+ LatestAt: input.LatestAt,
+ DescendantCount: input.DescendantCount,
+ DescendantTokenCount: input.DescendantTokenCount,
+ SourceMessageTokenCount: input.SourceMessageTokens,
+ Model: input.Model,
+ CreatedAt: now,
+ }, nil
+}
+
+// GetSummary retrieves a summary by ID.
+func (s *Store) GetSummary(ctx context.Context, summaryID string) (*Summary, error) {
+ return s.scanSummary(ctx, "WHERE summary_id = ?", summaryID)
+}
+
+// GetSummariesByConversation retrieves all summaries for a conversation.
+func (s *Store) GetSummariesByConversation(ctx context.Context, convID int64) ([]Summary, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT summary_id, conversation_id, kind, depth, content, token_count,
+ earliest_at, latest_at, descendant_count, descendant_token_count,
+ source_message_token_count, model, created_at
+ FROM summaries WHERE conversation_id = ? ORDER BY created_at`,
+ convID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return s.scanSummaries(rows)
+}
+
+// GetSummaryChildren retrieves child summary IDs (summaries that list this summary as parent).
+func (s *Store) GetSummaryChildren(ctx context.Context, summaryID string) ([]string, error) {
+ rows, err := s.db.QueryContext(ctx,
+ "SELECT summary_id FROM summary_parents WHERE parent_summary_id = ?",
+ summaryID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var ids []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ ids = append(ids, id)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return ids, nil
+}
+
+// GetSummaryParents retrieves parent summaries (full objects) for a summary.
+func (s *Store) GetSummaryParents(ctx context.Context, summaryID string) ([]Summary, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT s.summary_id, s.conversation_id, s.kind, s.depth, s.content, s.token_count,
+ s.earliest_at, s.latest_at, s.descendant_count, s.descendant_token_count,
+ s.source_message_token_count, s.model, s.created_at
+ FROM summary_parents sp
+ JOIN summaries s ON s.summary_id = sp.parent_summary_id
+ WHERE sp.summary_id = ?`,
+ summaryID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return s.scanSummaries(rows)
+}
+
+// LinkSummaryToMessages links a leaf summary to its source messages.
+func (s *Store) LinkSummaryToMessages(ctx context.Context, summaryID string, messageIDs []int64) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ for i, msgID := range messageIDs {
+ _, err = tx.ExecContext(ctx,
+ "INSERT OR IGNORE INTO summary_messages (summary_id, message_id, ordinal) VALUES (?, ?, ?)",
+ summaryID, msgID, i,
+ )
+ if err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+// GetSummarySourceMessages retrieves source messages for a summary.
+func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string) ([]Message, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT m.message_id, m.conversation_id, m.role, m.content, m.reasoning_content, m.token_count, m.created_at
+ FROM summary_messages sm
+ JOIN messages m ON m.message_id = sm.message_id
+ WHERE sm.summary_id = ?
+ ORDER BY sm.ordinal`,
+ summaryID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var msgs []Message
+ for rows.Next() {
+ var msg Message
+ var createdAt string
+ if err := rows.Scan(
+ &msg.ID,
+ &msg.ConversationID,
+ &msg.Role,
+ &msg.Content,
+ &msg.ReasoningContent,
+ &msg.TokenCount,
+ &createdAt,
+ ); err != nil {
+ return nil, err
+ }
+ msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ msgs = append(msgs, msg)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return msgs, nil
+}
+
+// GetRootSummaries retrieves root summaries (not children of any other summary).
+func (s *Store) GetRootSummaries(ctx context.Context, convID int64) ([]Summary, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT s.summary_id, s.conversation_id, s.kind, s.depth, s.content, s.token_count,
+ s.earliest_at, s.latest_at, s.descendant_count, s.descendant_token_count,
+ s.source_message_token_count, s.model, s.created_at
+ FROM summaries s
+ WHERE s.conversation_id = ?
+ AND s.summary_id NOT IN (SELECT sp.parent_summary_id FROM summary_parents sp)
+ ORDER BY s.created_at`,
+ convID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return s.scanSummaries(rows)
+}
+
+// --- Context Item Operations ---
+
+// GetContextItems retrieves context items for a conversation, ordered by ordinal.
+func (s *Store) GetContextItems(ctx context.Context, convID int64) ([]ContextItem, error) {
+ rows, err := s.db.QueryContext(
+ ctx,
+ "SELECT ordinal, item_type, summary_id, message_id, token_count, created_at FROM context_items WHERE conversation_id = ? ORDER BY ordinal",
+ convID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var items []ContextItem
+ for rows.Next() {
+ var item ContextItem
+ var summaryID sql.NullString
+ var messageID sql.NullInt64
+ var createdAt sql.NullString
+ if err := rows.Scan(
+ &item.Ordinal,
+ &item.ItemType,
+ &summaryID,
+ &messageID,
+ &item.TokenCount,
+ &createdAt,
+ ); err != nil {
+ return nil, err
+ }
+ item.ConversationID = convID
+ if summaryID.Valid {
+ item.SummaryID = summaryID.String
+ }
+ if messageID.Valid {
+ item.MessageID = messageID.Int64
+ }
+ if createdAt.Valid {
+ t, _ := time.Parse("2006-01-02 15:04:05", createdAt.String)
+ item.CreatedAt = t
+ }
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+// UpsertContextItems replaces all context items for a conversation.
+func (s *Store) UpsertContextItems(ctx context.Context, convID int64, items []ContextItem) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ _, err = tx.ExecContext(ctx, "DELETE FROM context_items WHERE conversation_id = ?", convID)
+ if err != nil {
+ return err
+ }
+
+ for _, item := range items {
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id, message_id, token_count)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ convID, item.Ordinal, item.ItemType,
+ nullString(item.SummaryID), nullInt64(item.MessageID),
+ item.TokenCount,
+ )
+ if err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+// ClearContextItems removes all context items for a conversation.
+func (s *Store) ClearContextItems(ctx context.Context, convID int64) error {
+ _, err := s.db.ExecContext(ctx, "DELETE FROM context_items WHERE conversation_id = ?", convID)
+ return err
+}
+
+// DeleteMessagesAfterID deletes all messages with ID > afterID for a conversation.
+// Also clears related context_items, message_parts, summary_messages, and FTS entries.
+// Uses transaction to ensure atomicity of the delete cascade.
+func (s *Store) DeleteMessagesAfterID(ctx context.Context, convID int64, afterID int64) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // Get message IDs to delete for cleaning up related tables
+ rows, err := tx.QueryContext(ctx,
+ "SELECT message_id FROM messages WHERE conversation_id = ? AND message_id > ?", convID, afterID)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ var msgIDs []int64
+ for rows.Next() {
+ var id int64
+ if scanErr := rows.Scan(&id); scanErr != nil {
+ return scanErr
+ }
+ msgIDs = append(msgIDs, id)
+ }
+ if rows.Err() != nil {
+ return rows.Err()
+ }
+
+ // Delete context_items referencing these messages
+ for _, msgID := range msgIDs {
+ if _, err := tx.ExecContext(ctx, "DELETE FROM context_items WHERE message_id = ?", msgID); err != nil {
+ return err
+ }
+ }
+
+ // Delete from message_parts and summary_messages
+ // Note: messages_fts is handled automatically by trigger, no manual delete needed
+ for _, msgID := range msgIDs {
+ if _, err := tx.ExecContext(ctx, "DELETE FROM message_parts WHERE message_id = ?", msgID); err != nil {
+ return err
+ }
+ if _, err := tx.ExecContext(ctx, "DELETE FROM summary_messages WHERE message_id = ?", msgID); err != nil {
+ return err
+ }
+ }
+
+ // Delete messages
+ if _, err := tx.ExecContext(ctx,
+ "DELETE FROM messages WHERE conversation_id = ? AND message_id > ?", convID, afterID); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// ClearConversation removes all data for a conversation from all tables.
+// Deletes context_items, summary_messages, summary_parents (via subquery), summaries,
+// message_parts, and messages. FTS entries are handled automatically by triggers.
+// Uses a transaction for atomicity.
+func (s *Store) ClearConversation(ctx context.Context, convID int64) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // Delete in child→parent order. FTS tables (messages_fts, summaries_fts) are
+ // kept in sync by DELETE triggers, so we just delete from the parent tables.
+
+ if _, err := tx.ExecContext(ctx,
+ "DELETE FROM context_items WHERE conversation_id = ?", convID); err != nil {
+ return fmt.Errorf("context_items: %w", err)
+ }
+ if _, err := tx.ExecContext(ctx,
+ `DELETE FROM summary_messages WHERE summary_id IN (
+ SELECT summary_id FROM summaries WHERE conversation_id = ?
+ )`, convID); err != nil {
+ return fmt.Errorf("summary_messages: %w", err)
+ }
+ // Note: summary_parents has no convID column; delete via subquery on summaries
+ if _, err := tx.ExecContext(ctx,
+ `DELETE FROM summary_parents WHERE summary_id IN (
+ SELECT summary_id FROM summaries WHERE conversation_id = ?
+ ) OR parent_summary_id IN (
+ SELECT summary_id FROM summaries WHERE conversation_id = ?
+ )`, convID, convID); err != nil {
+ return fmt.Errorf("summary_parents: %w", err)
+ }
+ if _, err := tx.ExecContext(ctx,
+ "DELETE FROM summaries WHERE conversation_id = ?", convID); err != nil {
+ return fmt.Errorf("summaries: %w", err)
+ }
+ if _, err := tx.ExecContext(ctx,
+ `DELETE FROM message_parts WHERE message_id IN (
+ SELECT message_id FROM messages WHERE conversation_id = ?
+ )`, convID); err != nil {
+ return fmt.Errorf("message_parts: %w", err)
+ }
+ if _, err := tx.ExecContext(ctx,
+ "DELETE FROM messages WHERE conversation_id = ?", convID); err != nil {
+ return fmt.Errorf("messages: %w", err)
+ }
+
+ return tx.Commit()
+}
+
+// AppendContextMessage appends a single message to context_items at next ordinal.
+func (s *Store) AppendContextMessage(ctx context.Context, convID int64, messageID int64) error {
+ return s.appendContextItems(ctx, convID, []ContextItem{
+ {ItemType: "message", MessageID: messageID},
+ })
+}
+
+// AppendContextMessages bulk-appends messages to context_items.
+func (s *Store) AppendContextMessages(ctx context.Context, convID int64, messageIDs []int64) error {
+ items := make([]ContextItem, len(messageIDs))
+ for i, id := range messageIDs {
+ items[i] = ContextItem{ItemType: "message", MessageID: id}
+ }
+ return s.appendContextItems(ctx, convID, items)
+}
+
+// AppendContextSummary appends a summary to context_items at next ordinal.
+func (s *Store) AppendContextSummary(ctx context.Context, convID int64, summaryID string) error {
+ return s.appendContextItems(ctx, convID, []ContextItem{
+ {ItemType: "summary", SummaryID: summaryID},
+ })
+}
+
+func (s *Store) appendContextItems(ctx context.Context, convID int64, items []ContextItem) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ maxOrd, err := s.GetMaxOrdinalTx(ctx, tx, convID)
+ if err != nil {
+ return err
+ }
+
+ ordinal := maxOrd + OrdinalStep
+ for _, item := range items {
+ item.ConversationID = convID
+ item.Ordinal = ordinal
+
+ // Resolve token count if not set
+ tokenCount := item.TokenCount
+ if tokenCount == 0 {
+ tokenCount = s.resolveItemTokenCountTx(ctx, tx, item)
+ }
+
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id, message_id, token_count)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ convID, ordinal, item.ItemType,
+ nullString(item.SummaryID), nullInt64(item.MessageID),
+ tokenCount,
+ )
+ if err != nil {
+ return err
+ }
+ ordinal += OrdinalStep
+ }
+ return tx.Commit()
+}
+
+// resolveItemTokenCountTx looks up token count within a transaction.
+func (s *Store) resolveItemTokenCountTx(ctx context.Context, tx *sql.Tx, item ContextItem) int {
+ if item.ItemType == "message" && item.MessageID > 0 {
+ var tc int
+ err := tx.QueryRowContext(ctx,
+ "SELECT token_count FROM messages WHERE message_id = ?", item.MessageID,
+ ).Scan(&tc)
+ if err == nil {
+ return tc
+ }
+ }
+ if item.ItemType == "summary" && item.SummaryID != "" {
+ var tc int
+ err := tx.QueryRowContext(ctx,
+ "SELECT token_count FROM summaries WHERE summary_id = ?", item.SummaryID,
+ ).Scan(&tc)
+ if err == nil {
+ return tc
+ }
+ }
+ return 0
+}
+
+// ReplaceContextRangeWithSummary atomically replaces a range of context items with a summary.
+// If ordinal gap is exhausted, triggers resequencing (spec lines 1204-1209).
+func (s *Store) ReplaceContextRangeWithSummary(
+ ctx context.Context,
+ convID int64,
+ startOrdinal, endOrdinal int,
+ summaryID string,
+) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // Delete the range
+ _, err = tx.ExecContext(ctx,
+ "DELETE FROM context_items WHERE conversation_id = ? AND ordinal >= ? AND ordinal <= ?",
+ convID, startOrdinal, endOrdinal,
+ )
+ if err != nil {
+ return err
+ }
+
+ // Insert summary at midpoint of replaced range
+ midpoint := (startOrdinal + endOrdinal) / 2
+
+ // Check if midpoint conflicts with existing ordinal
+ var conflict bool
+ var existingOrd int
+ err = tx.QueryRowContext(ctx,
+ "SELECT ordinal FROM context_items WHERE conversation_id = ? AND ordinal = ?",
+ convID, midpoint,
+ ).Scan(&existingOrd)
+ if err == nil {
+ conflict = true
+ }
+
+ if conflict {
+ // Gap exhausted, need resequence (spec lines 1204-1209)
+ err = s.resequenceContextItemsTx(ctx, tx, convID, summaryID)
+ if err != nil {
+ return fmt.Errorf("resequence: %w", err)
+ }
+ } else {
+ // Normal insert at midpoint with token_count from summary
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id, token_count)
+ SELECT ?, ?, 'summary', ?, token_count FROM summaries WHERE summary_id = ?`,
+ convID, midpoint, summaryID, summaryID,
+ )
+ if err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
+
+// ReplaceContextItemsWithSummary replaces specific context items (by summary_id) with a new summary.
+// Use this when candidates are not contiguous in ordinal space to avoid deleting non-candidate items.
+func (s *Store) ReplaceContextItemsWithSummary(
+ ctx context.Context,
+ convID int64,
+ summaryIDs []string,
+ newSummaryID string,
+) error {
+ if len(summaryIDs) == 0 {
+ return nil
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // Find the ordinals of items to delete and calculate midpoint
+ placeholders := make([]string, len(summaryIDs))
+ args := make([]any, len(summaryIDs)+1)
+ args[0] = convID
+ for i, sid := range summaryIDs {
+ placeholders[i] = "?"
+ args[i+1] = sid
+ }
+
+ query := fmt.Sprintf(
+ "SELECT ordinal FROM context_items WHERE conversation_id = ? AND summary_id IN (%s) ORDER BY ordinal",
+ strings.Join(placeholders, ","),
+ )
+ rows, err := tx.QueryContext(ctx, query, args...)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ var ordinals []int
+ for rows.Next() {
+ var ord int
+ if scanErr := rows.Scan(&ord); scanErr != nil {
+ return scanErr
+ }
+ ordinals = append(ordinals, ord)
+ }
+ if err = rows.Err(); err != nil {
+ return err
+ }
+
+ if len(ordinals) == 0 {
+ return nil
+ }
+
+ midpoint := (ordinals[0] + ordinals[len(ordinals)-1]) / 2
+
+ // Delete the specific items by summary_id
+ deleteQuery := fmt.Sprintf(
+ "DELETE FROM context_items WHERE conversation_id = ? AND summary_id IN (%s)",
+ strings.Join(placeholders, ","),
+ )
+ _, err = tx.ExecContext(ctx, deleteQuery, args...)
+ if err != nil {
+ return err
+ }
+
+ // Check if midpoint conflicts with existing ordinal
+ var conflict bool
+ var existingOrd int
+ err = tx.QueryRowContext(ctx,
+ "SELECT ordinal FROM context_items WHERE conversation_id = ? AND ordinal = ?",
+ convID, midpoint,
+ ).Scan(&existingOrd)
+ if err == nil {
+ conflict = true
+ }
+
+ if conflict {
+ // Gap exhausted, need resequence
+ err = s.resequenceContextItemsTx(ctx, tx, convID, newSummaryID)
+ if err != nil {
+ return fmt.Errorf("resequence: %w", err)
+ }
+ } else {
+ // Normal insert at midpoint
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id, token_count)
+ SELECT ?, ?, 'summary', ?, token_count FROM summaries WHERE summary_id = ?`,
+ convID, midpoint, newSummaryID, newSummaryID,
+ )
+ if err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
+
+// resequenceContextItemsTx renumbers context_items with fresh OrdinalStep gaps.
+// Uses temp negative ordinals to avoid PRIMARY KEY constraint violations (spec lines 1240-1247).
+func (s *Store) resequenceContextItemsTx(ctx context.Context, tx *sql.Tx, convID int64, newSummaryID string) error {
+ // Get all remaining items sorted by current ordinal
+ rows, err := tx.QueryContext(
+ ctx,
+ "SELECT ordinal, item_type, summary_id, message_id, token_count FROM context_items WHERE conversation_id = ? ORDER BY ordinal",
+ convID,
+ )
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ type item struct {
+ ordinal int
+ itemType string
+ summaryID string
+ messageID int64
+ tokenCount int
+ }
+ var items []item
+ for rows.Next() {
+ var i item
+ var sid sql.NullString
+ var mid sql.NullInt64
+ var scanErr error
+ if scanErr = rows.Scan(&i.ordinal, &i.itemType, &sid, &mid, &i.tokenCount); scanErr != nil {
+ return scanErr
+ }
+ if sid.Valid {
+ i.summaryID = sid.String
+ }
+ if mid.Valid {
+ i.messageID = mid.Int64
+ }
+ items = append(items, i)
+ }
+ if rowsErr := rows.Err(); rowsErr != nil {
+ return rowsErr
+ }
+
+ // Step 1: Move all items to temp negative ordinals
+ tempOrd := -1
+ for _, i := range items {
+ _, execErr := tx.ExecContext(ctx,
+ "UPDATE context_items SET ordinal = ? WHERE conversation_id = ? AND ordinal = ?",
+ tempOrd, convID, i.ordinal,
+ )
+ if execErr != nil {
+ return execErr
+ }
+ tempOrd--
+ }
+
+ // Step 2: Insert new summary at the end with positive ordinal
+ // Include token_count from summaries table
+ newOrd := (len(items) + 1) * OrdinalStep
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id, token_count)
+ SELECT ?, ?, 'summary', ?, token_count FROM summaries WHERE summary_id = ?`,
+ convID, newOrd, newSummaryID, newSummaryID,
+ )
+ if err != nil {
+ return err
+ }
+
+ // Step 3: Update each temp item to its final positive ordinal
+ // Use specific temp ordinal matching (not ordinal < 0) to avoid updating all items
+ finalOrd := OrdinalStep
+ tempOrd = -1 // Reset to first temp ordinal (already declared in Step 1)
+ for range items {
+ _, execErr := tx.ExecContext(ctx,
+ "UPDATE context_items SET ordinal = ? WHERE conversation_id = ? AND ordinal = ?",
+ finalOrd, convID, tempOrd,
+ )
+ if execErr != nil {
+ return execErr
+ }
+ finalOrd += OrdinalStep
+ tempOrd--
+ }
+
+ return nil
+}
+
+// GetContextTokenCount returns total token count for all items in context.
+func (s *Store) GetContextTokenCount(ctx context.Context, convID int64) (int, error) {
+ var count int
+ err := s.db.QueryRowContext(ctx,
+ "SELECT COALESCE(SUM(token_count), 0) FROM context_items WHERE conversation_id = ?",
+ convID,
+ ).Scan(&count)
+ return count, err
+}
+
+// GetMaxOrdinal returns the highest ordinal in context_items for a conversation.
+func (s *Store) GetMaxOrdinal(ctx context.Context, convID int64) (int, error) {
+ var maxOrd sql.NullInt64
+ err := s.db.QueryRowContext(ctx,
+ "SELECT MAX(ordinal) FROM context_items WHERE conversation_id = ?",
+ convID,
+ ).Scan(&maxOrd)
+ if err != nil {
+ return 0, err
+ }
+ if !maxOrd.Valid {
+ return 0, nil
+ }
+ return int(maxOrd.Int64), nil
+}
+
+// GetMaxOrdinalTx returns the highest ordinal within a transaction.
+func (s *Store) GetMaxOrdinalTx(ctx context.Context, tx *sql.Tx, convID int64) (int, error) {
+ var maxOrd sql.NullInt64
+ err := tx.QueryRowContext(ctx,
+ "SELECT MAX(ordinal) FROM context_items WHERE conversation_id = ?",
+ convID,
+ ).Scan(&maxOrd)
+ if err != nil {
+ return 0, err
+ }
+ if !maxOrd.Valid {
+ return 0, nil
+ }
+ return int(maxOrd.Int64), nil
+}
+
+// GetDistinctDepthsInContext returns distinct depth levels of summaries currently in context.
+// maxOrdinalExclusive filters out summaries with ordinal >= this value (0 = no filter).
+func (s *Store) GetDistinctDepthsInContext(ctx context.Context, convID int64, maxOrdinalExclusive int) ([]int, error) {
+ query := `SELECT DISTINCT s.depth
+ FROM context_items ci
+ JOIN summaries s ON s.summary_id = ci.summary_id
+ WHERE ci.conversation_id = ? AND ci.item_type = 'summary'`
+ args := []any{convID}
+
+ if maxOrdinalExclusive > 0 {
+ query += " AND ci.ordinal < ?"
+ args = append(args, maxOrdinalExclusive)
+ }
+
+ query += " ORDER BY s.depth"
+
+ rows, err := s.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("get distinct depths: %w", err)
+ }
+ defer rows.Close()
+
+ var depths []int
+ for rows.Next() {
+ var d int
+ if err := rows.Scan(&d); err != nil {
+ return nil, err
+ }
+ depths = append(depths, d)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return depths, nil
+}
+
+// GetSummarySubtree returns all summaries in the subtree rooted at summaryID,
+// including summaryID itself. Uses a recursive CTE to traverse the DAG.
+func (s *Store) GetSummarySubtree(ctx context.Context, summaryID string) ([]SummarySubtreeNode, error) {
+ rows, err := s.db.QueryContext(ctx, `
+ WITH RECURSIVE subtree AS (
+ SELECT summary_id, 0 AS depth_from_root
+ FROM summaries
+ WHERE summary_id = ?
+ UNION ALL
+ SELECT sp.parent_summary_id, st.depth_from_root + 1
+ FROM summary_parents sp
+ JOIN subtree st ON sp.summary_id = st.summary_id
+ )
+ SELECT summary_id, depth_from_root FROM subtree`,
+ summaryID,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("get summary subtree: %w", err)
+ }
+ defer rows.Close()
+
+ var nodes []SummarySubtreeNode
+ for rows.Next() {
+ var n SummarySubtreeNode
+ if err := rows.Scan(&n.SummaryID, &n.DepthFromRoot); err != nil {
+ return nil, err
+ }
+ nodes = append(nodes, n)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return nodes, nil
+}
+
+// --- Search Operations ---
+
+// SearchSummaries performs full-text search on summaries.
+func (s *Store) SearchSummaries(ctx context.Context, input SearchInput) ([]SearchResult, error) {
+ // "like" → LIKE search, anything else (including "full_text" or empty) → FTS5
+ if input.Mode == "like" {
+ return s.searchSummariesLike(ctx, input)
+ }
+ return s.searchSummariesFTS(ctx, input)
+}
+
+func (s *Store) searchSummariesFTS(ctx context.Context, input SearchInput) ([]SearchResult, error) {
+ sanitized := SanitizeFTS5Query(input.Pattern)
+ if sanitized == "" {
+ return nil, nil
+ }
+
+ // Build WHERE clause for filters (used in both count and data queries)
+ whereClauses := []string{"summaries_fts MATCH ?"}
+ args := []any{sanitized}
+
+ if input.ConversationID > 0 && !input.AllConversations {
+ whereClauses = append(whereClauses, "s.conversation_id = ?")
+ args = append(args, input.ConversationID)
+ }
+
+ if input.Since != nil {
+ whereClauses = append(whereClauses, "s.created_at >= ?")
+ args = append(args, input.Since.Format("2006-01-02 15:04:05"))
+ }
+ if input.Before != nil {
+ whereClauses = append(whereClauses, "s.created_at < ?")
+ args = append(args, input.Before.Format("2006-01-02 15:04:05"))
+ }
+
+ whereStr := strings.Join(whereClauses, " AND ")
+
+ // First, get total count (bm25 conflicts with window functions in FTS5)
+ countQuery := `SELECT COUNT(*) FROM summaries_fts fts
+ JOIN summaries s ON s.summary_id = fts.summary_id
+ WHERE ` + whereStr
+ var totalCount int
+ if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount); err != nil {
+ return nil, err
+ }
+
+ // Then, get actual results with bm25 ranking
+ dataQuery := `SELECT s.summary_id, s.conversation_id, s.kind, s.content, s.created_at, bm25(summaries_fts) as rank
+ FROM summaries_fts fts
+ JOIN summaries s ON s.summary_id = fts.summary_id
+ WHERE ` + whereStr + ` ORDER BY rank`
+
+ dataArgs := append([]any{}, args...) // copy args
+ if input.Limit > 0 {
+ dataQuery += " LIMIT ?"
+ dataArgs = append(dataArgs, input.Limit)
+ }
+
+ rows, err := s.db.QueryContext(ctx, dataQuery, dataArgs...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ results, err := s.scanSearchResults(rows, true)
+ if err != nil {
+ return nil, err
+ }
+
+ // Set total count on all results
+ for i := range results {
+ results[i].TotalCount = totalCount
+ }
+ return results, nil
+}
+
+// buildLikeQuery appends conversation/time filters and limit to a LIKE query.
+// Note: role filtering is NOT applied here since summaries don't have role column.
+// Use buildMessagesLikeQuery for message searches that need role filtering.
+func buildLikeQuery(query string, args []any, input SearchInput) (string, []any) {
+ if input.ConversationID > 0 && !input.AllConversations {
+ query += " AND conversation_id = ?"
+ args = append(args, input.ConversationID)
+ }
+ if input.Since != nil {
+ query += " AND created_at >= ?"
+ args = append(args, input.Since.Format("2006-01-02 15:04:05"))
+ }
+ if input.Before != nil {
+ query += " AND created_at < ?"
+ args = append(args, input.Before.Format("2006-01-02 15:04:05"))
+ }
+ // Order by newest first for LIKE mode
+ query += " ORDER BY created_at DESC"
+ if input.Limit > 0 {
+ query += " LIMIT ?"
+ args = append(args, input.Limit)
+ }
+ return query, args
+}
+
+// buildMessagesLikeQuery is like buildLikeQuery but adds role filtering for messages.
+func buildMessagesLikeQuery(query string, args []any, input SearchInput) (string, []any) {
+ if input.Role != "" {
+ query += " AND role = ?"
+ args = append(args, input.Role)
+ }
+ return buildLikeQuery(query, args, input)
+}
+
+func (s *Store) searchSummariesLike(ctx context.Context, input SearchInput) ([]SearchResult, error) {
+ query := `SELECT summary_id, conversation_id, kind, content, created_at, COUNT(*) OVER() as total_count
+ FROM summaries WHERE content LIKE ?`
+ args := []any{"%" + input.Pattern + "%"}
+ query, args = buildLikeQuery(query, args, input)
+
+ rows, err := s.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ return s.scanSearchResults(rows, false)
+}
+
+func (s *Store) scanSearchResults(rows *sql.Rows, withRank bool) ([]SearchResult, error) {
+ var results []SearchResult
+ for rows.Next() {
+ var r SearchResult
+ var createdAt string
+ var kind string
+ if withRank {
+ // FTS5 mode: no TotalCount in query (set by caller after COUNT)
+ if err := rows.Scan(&r.SummaryID, &r.ConversationID, &kind, &r.Content, &createdAt, &r.Rank); err != nil {
+ return nil, err
+ }
+ } else {
+ // LIKE mode: TotalCount from window function
+ if err := rows.Scan(&r.SummaryID, &r.ConversationID, &kind,
+ &r.Content, &createdAt, &r.TotalCount); err != nil {
+ return nil, err
+ }
+ }
+ r.Kind = SummaryKind(kind)
+ r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ results = append(results, r)
+ }
+ return results, nil
+}
+
+// SearchMessages performs full-text or regex search on messages.
+func (s *Store) SearchMessages(ctx context.Context, input SearchInput) ([]SearchResult, error) {
+ // Try FTS5 first for full-text mode
+ if input.Mode == "" || input.Mode == "full_text" {
+ results, err := s.searchMessagesFTS(ctx, input)
+ if err == nil && len(results) > 0 {
+ return results, nil
+ }
+ // Fall through to LIKE
+ }
+
+ return s.searchMessagesLike(ctx, input)
+}
+
+func (s *Store) searchMessagesFTS(ctx context.Context, input SearchInput) ([]SearchResult, error) {
+ sanitized := SanitizeFTS5Query(input.Pattern)
+ if sanitized == "" {
+ return nil, nil
+ }
+
+ // Build WHERE clause for filters (used in both count and data queries)
+ whereClauses := []string{"messages_fts MATCH ?"}
+ args := []any{sanitized}
+
+ if input.ConversationID > 0 && !input.AllConversations {
+ whereClauses = append(whereClauses, "m.conversation_id = ?")
+ args = append(args, input.ConversationID)
+ }
+
+ if input.Role != "" {
+ whereClauses = append(whereClauses, "m.role = ?")
+ args = append(args, input.Role)
+ }
+
+ if input.Since != nil {
+ whereClauses = append(whereClauses, "m.created_at >= ?")
+ args = append(args, input.Since.Format("2006-01-02 15:04:05"))
+ }
+ if input.Before != nil {
+ whereClauses = append(whereClauses, "m.created_at < ?")
+ args = append(args, input.Before.Format("2006-01-02 15:04:05"))
+ }
+
+ whereStr := strings.Join(whereClauses, " AND ")
+
+ // First, get total count (bm25 conflicts with window functions in FTS5)
+ countQuery := `SELECT COUNT(*) FROM messages_fts f
+ JOIN messages m ON f.message_id = m.message_id
+ WHERE ` + whereStr
+ var totalCount int
+ if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount); err != nil {
+ return nil, err
+ }
+
+ // Then, get actual results with bm25 ranking
+ dataQuery := `SELECT m.message_id, m.conversation_id, m.role, m.content, m.created_at, bm25(messages_fts) as rank
+ FROM messages_fts f
+ JOIN messages m ON f.message_id = m.message_id
+ WHERE ` + whereStr + ` ORDER BY rank`
+
+ dataArgs := append([]any{}, args...) // copy args
+ if input.Limit > 0 {
+ dataQuery += " LIMIT ?"
+ dataArgs = append(dataArgs, input.Limit)
+ }
+
+ rows, err := s.db.QueryContext(ctx, dataQuery, dataArgs...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ results, err := s.scanMessageSearchResults(rows, true)
+ if err != nil {
+ return nil, err
+ }
+
+ // Set total count on all results
+ for i := range results {
+ results[i].TotalCount = totalCount
+ }
+ return results, nil
+}
+
+func (s *Store) searchMessagesLike(ctx context.Context, input SearchInput) ([]SearchResult, error) {
+ query := `SELECT message_id, conversation_id, role, content, created_at, COUNT(*) OVER() as total_count
+ FROM messages WHERE content LIKE ?`
+ args := []any{"%" + input.Pattern + "%"}
+ query, args = buildMessagesLikeQuery(query, args, input)
+
+ rows, err := s.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ return s.scanMessageSearchResults(rows, false)
+}
+
+func (s *Store) scanMessageSearchResults(rows *sql.Rows, withRank bool) ([]SearchResult, error) {
+ var results []SearchResult
+ for rows.Next() {
+ var r SearchResult
+ var createdAt string
+ var content string
+ if withRank {
+ // FTS5 mode: no TotalCount in query (set by caller after COUNT)
+ if err := rows.Scan(&r.MessageID, &r.ConversationID, &r.Role, &content, &createdAt, &r.Rank); err != nil {
+ return nil, err
+ }
+ } else {
+ // LIKE mode: TotalCount from window function
+ if err := rows.Scan(&r.MessageID, &r.ConversationID, &r.Role, &content,
+ &createdAt, &r.TotalCount); err != nil {
+ return nil, err
+ }
+ }
+ r.Snippet = content
+ r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ results = append(results, r)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return results, nil
+}
+
+// --- Helpers ---
+
+func (s *Store) scanSummary(ctx context.Context, where string, args ...any) (*Summary, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT summary_id, conversation_id, kind, depth, content, token_count,
+ earliest_at, latest_at, descendant_count, descendant_token_count,
+ source_message_token_count, model, created_at
+ FROM summaries `+where, args...,
+ )
+ var sum Summary
+ var kind, createdAt string
+ var earliestAt, latestAt sql.NullString
+ err := row.Scan(
+ &sum.SummaryID, &sum.ConversationID, &kind, &sum.Depth, &sum.Content, &sum.TokenCount,
+ &earliestAt, &latestAt, &sum.DescendantCount, &sum.DescendantTokenCount,
+ &sum.SourceMessageTokenCount, &sum.Model, &createdAt,
+ )
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("summary not found")
+ }
+ if err != nil {
+ return nil, err
+ }
+ sum.Kind = SummaryKind(kind)
+ sum.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ if earliestAt.Valid {
+ t, _ := time.Parse(time.RFC3339, earliestAt.String)
+ sum.EarliestAt = &t
+ }
+ if latestAt.Valid {
+ t, _ := time.Parse(time.RFC3339, latestAt.String)
+ sum.LatestAt = &t
+ }
+ return &sum, nil
+}
+
+func (s *Store) scanSummaries(rows *sql.Rows) ([]Summary, error) {
+ var summaries []Summary
+ for rows.Next() {
+ var sum Summary
+ var kind, createdAt string
+ var earliestAt, latestAt sql.NullString
+ err := rows.Scan(
+ &sum.SummaryID, &sum.ConversationID, &kind, &sum.Depth, &sum.Content, &sum.TokenCount,
+ &earliestAt, &latestAt, &sum.DescendantCount, &sum.DescendantTokenCount,
+ &sum.SourceMessageTokenCount, &sum.Model, &createdAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+ sum.Kind = SummaryKind(kind)
+ sum.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ if earliestAt.Valid {
+ t, _ := time.Parse(time.RFC3339, earliestAt.String)
+ sum.EarliestAt = &t
+ }
+ if latestAt.Valid {
+ t, _ := time.Parse(time.RFC3339, latestAt.String)
+ sum.LatestAt = &t
+ }
+ summaries = append(summaries, sum)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return summaries, nil
+}
+
+func generateSummaryID(content string, t time.Time) string {
+ return fmt.Sprintf("sum_%x", t.UnixNano())
+}
+
+func isUniqueViolation(err error) bool {
+ return err != nil && (contains(err.Error(), "UNIQUE constraint failed") ||
+ contains(err.Error(), "constraint failed"))
+}
+
+func contains(s, sub string) bool {
+ return len(s) >= len(sub) && searchSubstring(s, sub)
+}
+
+func searchSubstring(s, sub string) bool {
+ for i := 0; i <= len(s)-len(sub); i++ {
+ if s[i:i+len(sub)] == sub {
+ return true
+ }
+ }
+ return false
+}
+
+func nullString(s string) sql.NullString {
+ return sql.NullString{String: s, Valid: s != ""}
+}
+
+func nullInt64(n int64) sql.NullInt64 {
+ return sql.NullInt64{Int64: n, Valid: n != 0}
+}
diff --git a/pkg/seahorse/store_test.go b/pkg/seahorse/store_test.go
new file mode 100644
index 000000000..67bed1c11
--- /dev/null
+++ b/pkg/seahorse/store_test.go
@@ -0,0 +1,1441 @@
+package seahorse
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+)
+
+func openTestStore(t *testing.T) *Store {
+ t.Helper()
+ db := openTestDB(t)
+ if err := runSchema(db); err != nil {
+ t.Fatalf("migration: %v", err)
+ }
+ return &Store{db: db}
+}
+
+// --- Conversation Operations ---
+
+func TestStoreGetOrCreateConversation(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, err := s.GetOrCreateConversation(ctx, "agent:abc123")
+ if err != nil {
+ t.Fatalf("GetOrCreateConversation: %v", err)
+ }
+ if conv.ConversationID == 0 {
+ t.Error("expected non-zero conversation ID")
+ }
+ if conv.SessionKey != "agent:abc123" {
+ t.Errorf("session key = %q, want %q", conv.SessionKey, "agent:abc123")
+ }
+
+ // Idempotent — same session key returns same conversation
+ conv2, err := s.GetOrCreateConversation(ctx, "agent:abc123")
+ if err != nil {
+ t.Fatalf("GetOrCreateConversation (2nd): %v", err)
+ }
+ if conv2.ConversationID != conv.ConversationID {
+ t.Errorf("idempotent: got ID %d, want %d", conv2.ConversationID, conv.ConversationID)
+ }
+
+ // Different session key → new conversation
+ conv3, err := s.GetOrCreateConversation(ctx, "agent:def456")
+ if err != nil {
+ t.Fatalf("GetOrCreateConversation (3rd): %v", err)
+ }
+ if conv3.ConversationID == conv.ConversationID {
+ t.Error("different session key should create different conversation")
+ }
+}
+
+func TestStoreGetConversationBySessionKey(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ // Not found
+ conv, err := s.GetConversationBySessionKey(ctx, "nonexistent")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if conv != nil {
+ t.Error("expected nil for nonexistent session key")
+ }
+
+ // Create then retrieve
+ created, err := s.GetOrCreateConversation(ctx, "agent:test")
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ found, err := s.GetConversationBySessionKey(ctx, "agent:test")
+ if err != nil {
+ t.Fatalf("find: %v", err)
+ }
+ if found.ConversationID != created.ConversationID {
+ t.Errorf("found ID %d, want %d", found.ConversationID, created.ConversationID)
+ }
+}
+
+// --- Conversation Clear ---
+
+func TestStoreClearConversation(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, err := s.GetOrCreateConversation(ctx, "agent:clear-test")
+ if err != nil {
+ t.Fatalf("create conversation: %v", err)
+ }
+
+ // Add messages
+ msg1, err := s.AddMessage(ctx, conv.ConversationID, "user", "hello", 5)
+ if err != nil {
+ t.Fatalf("add message 1: %v", err)
+ }
+ msg2, err := s.AddMessage(ctx, conv.ConversationID, "assistant", "hi", 5)
+ if err != nil {
+ t.Fatalf("add message 2: %v", err)
+ }
+
+ // Add a summary
+ _, err = s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Content: "test summary",
+ TokenCount: 10,
+ Kind: SummaryKindLeaf,
+ })
+ if err != nil {
+ t.Fatalf("create summary: %v", err)
+ }
+
+ // Verify data exists
+ msgs, err := s.GetMessages(ctx, conv.ConversationID, 0, 0)
+ if err != nil {
+ t.Fatalf("get messages before clear: %v", err)
+ }
+ if len(msgs) != 2 {
+ t.Fatalf("expected 2 messages before clear, got %d", len(msgs))
+ }
+
+ sums, err := s.GetSummariesByConversation(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("get summaries before clear: %v", err)
+ }
+ if len(sums) != 1 {
+ t.Fatalf("expected 1 summary before clear, got %d", len(sums))
+ }
+
+ // Clear
+ if err = s.ClearConversation(ctx, conv.ConversationID); err != nil {
+ t.Fatalf("clear conversation: %v", err)
+ }
+
+ // Verify all data is gone
+ msgs, err = s.GetMessages(ctx, conv.ConversationID, 0, 0)
+ if err != nil {
+ t.Fatalf("get messages after clear: %v", err)
+ }
+ if len(msgs) != 0 {
+ t.Fatalf("expected 0 messages after clear, got %d", len(msgs))
+ }
+
+ sums, err = s.GetSummariesByConversation(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("get summaries after clear: %v", err)
+ }
+ if len(sums) != 0 {
+ t.Fatalf("expected 0 summaries after clear, got %d", len(sums))
+ }
+
+ items, err := s.GetContextItems(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("get context items after clear: %v", err)
+ }
+ if len(items) != 0 {
+ t.Fatalf("expected 0 context items after clear, got %d", len(items))
+ }
+
+ var count int
+ if err := s.db.QueryRowContext(ctx,
+ "SELECT COUNT(*) FROM message_parts WHERE message_id = ? OR message_id = ?",
+ msg1.ID, msg2.ID).Scan(&count); err != nil {
+ t.Fatalf("count message parts: %v", err)
+ }
+ if count != 0 {
+ t.Fatalf("expected 0 message parts after clear, got %d", count)
+ }
+}
+
+func TestStoreAddAndGetMessages(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ msg, err := s.AddMessage(ctx, conv.ConversationID, "user", "hello world", 5)
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+ if msg.ID == 0 {
+ t.Error("expected non-zero message ID")
+ }
+ if msg.Role != "user" || msg.Content != "hello world" {
+ t.Errorf("message = %+v, want role=user content=hello world", msg)
+ }
+
+ // Retrieve
+ msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if err != nil {
+ t.Fatalf("GetMessages: %v", err)
+ }
+ if len(msgs) != 1 {
+ t.Fatalf("got %d messages, want 1", len(msgs))
+ }
+ if msgs[0].Content != "hello world" {
+ t.Errorf("content = %q, want %q", msgs[0].Content, "hello world")
+ }
+}
+
+func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:reasoning")
+
+ msg, err := s.AddMessageWithReasoning(
+ ctx,
+ conv.ConversationID,
+ "assistant",
+ "hello world",
+ "let me think",
+ 5,
+ )
+ if err != nil {
+ t.Fatalf("AddMessageWithReasoning: %v", err)
+ }
+ if msg.ReasoningContent != "let me think" {
+ t.Fatalf("ReasoningContent = %q, want %q", msg.ReasoningContent, "let me think")
+ }
+
+ msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if err != nil {
+ t.Fatalf("GetMessages: %v", err)
+ }
+ if len(msgs) != 1 {
+ t.Fatalf("got %d messages, want 1", len(msgs))
+ }
+ if msgs[0].ReasoningContent != "let me think" {
+ t.Errorf("ReasoningContent = %q, want %q", msgs[0].ReasoningContent, "let me think")
+ }
+
+ found, err := s.GetMessageByID(ctx, msg.ID)
+ if err != nil {
+ t.Fatalf("GetMessageByID: %v", err)
+ }
+ if found.ReasoningContent != "let me think" {
+ t.Errorf("GetMessageByID ReasoningContent = %q, want %q", found.ReasoningContent, "let me think")
+ }
+}
+
+func TestStoreAddMessageWithParts(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ parts := []MessagePart{
+ {Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"},
+ {Type: "text", Text: "some output"},
+ }
+ msg, err := s.AddMessageWithParts(ctx, conv.ConversationID, "assistant", parts, 10)
+ if err != nil {
+ t.Fatalf("AddMessageWithParts: %v", err)
+ }
+ if msg.ID == 0 {
+ t.Error("expected non-zero message ID")
+ }
+
+ // Retrieve and verify parts
+ msgs, _ := s.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if len(msgs) != 1 {
+ t.Fatalf("expected 1 message, got %d", len(msgs))
+ }
+ if len(msgs[0].Parts) != 2 {
+ t.Fatalf("expected 2 parts, got %d", len(msgs[0].Parts))
+ }
+ if msgs[0].Parts[0].Type != "tool_use" {
+ t.Errorf("part[0].Type = %q, want tool_use", msgs[0].Parts[0].Type)
+ }
+ if msgs[0].Parts[0].ToolCallID != "tc_123" {
+ t.Errorf("part[0].ToolCallID = %q, want tc_123", msgs[0].Parts[0].ToolCallID)
+ }
+}
+
+func TestStoreAddMessageWithPartsAndReasoningContent(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:parts-reasoning")
+
+ parts := []MessagePart{
+ {Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"},
+ }
+ _, err := s.AddMessageWithPartsAndReasoning(
+ ctx,
+ conv.ConversationID,
+ "assistant",
+ parts,
+ "need to inspect the file first",
+ 10,
+ )
+ if err != nil {
+ t.Fatalf("AddMessageWithPartsAndReasoning: %v", err)
+ }
+
+ msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if err != nil {
+ t.Fatalf("GetMessages: %v", err)
+ }
+ if len(msgs) != 1 {
+ t.Fatalf("expected 1 message, got %d", len(msgs))
+ }
+ if msgs[0].ReasoningContent != "need to inspect the file first" {
+ t.Errorf(
+ "ReasoningContent = %q, want %q",
+ msgs[0].ReasoningContent,
+ "need to inspect the file first",
+ )
+ }
+}
+
+func TestStoreGetMessageCount(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ s.AddMessage(ctx, conv.ConversationID, "user", "msg1", 2)
+ s.AddMessage(ctx, conv.ConversationID, "assistant", "msg2", 3)
+ s.AddMessage(ctx, conv.ConversationID, "user", "msg3", 1)
+
+ count, err := s.GetMessageCount(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("GetMessageCount: %v", err)
+ }
+ if count != 3 {
+ t.Errorf("count = %d, want 3", count)
+ }
+}
+
+func TestStoreGetMessageByID(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ msg, _ := s.AddMessage(ctx, conv.ConversationID, "user", "find me", 3)
+
+ found, err := s.GetMessageByID(ctx, msg.ID)
+ if err != nil {
+ t.Fatalf("GetMessageByID: %v", err)
+ }
+ if found.Content != "find me" {
+ t.Errorf("content = %q, want %q", found.Content, "find me")
+ }
+
+ // Not found
+ _, err = s.GetMessageByID(ctx, 99999)
+ if err == nil {
+ t.Error("expected error for nonexistent message")
+ }
+}
+
+func TestStoreUpdateMessageReasoningContent(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:update-reasoning")
+
+ msg, err := s.AddMessage(ctx, conv.ConversationID, "assistant", "answer", 3)
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+
+ err = s.UpdateMessageReasoningContent(ctx, msg.ID, "thinking")
+ if err != nil {
+ t.Fatalf("UpdateMessageReasoningContent: %v", err)
+ }
+
+ found, err := s.GetMessageByID(ctx, msg.ID)
+ if err != nil {
+ t.Fatalf("GetMessageByID: %v", err)
+ }
+ if found.ReasoningContent != "thinking" {
+ t.Errorf("ReasoningContent = %q, want %q", found.ReasoningContent, "thinking")
+ }
+}
+
+// --- Summary Operations ---
+
+func TestStoreCreateAndGetSummary(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ now := time.Now().UTC().Truncate(time.Second)
+ summary, err := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "test summary content",
+ TokenCount: 50,
+ EarliestAt: &now,
+ LatestAt: &now,
+ DescendantCount: 0,
+ DescendantTokenCount: 0,
+ SourceMessageTokens: 500,
+ Model: "test-model",
+ })
+ if err != nil {
+ t.Fatalf("CreateSummary: %v", err)
+ }
+ if summary.SummaryID == "" {
+ t.Error("expected non-empty summary ID")
+ }
+ if summary.Kind != SummaryKindLeaf {
+ t.Errorf("kind = %q, want leaf", summary.Kind)
+ }
+
+ // Retrieve by ID
+ found, err := s.GetSummary(ctx, summary.SummaryID)
+ if err != nil {
+ t.Fatalf("GetSummary: %v", err)
+ }
+ if found.Content != "test summary content" {
+ t.Errorf("content = %q, want 'test summary content'", found.Content)
+ }
+ if found.SourceMessageTokenCount != 500 {
+ t.Errorf("source_message_token_count = %d, want 500", found.SourceMessageTokenCount)
+ }
+}
+
+func TestStoreSummaryDAG(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // Create leaf summaries
+ leaf1, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "leaf 1",
+ TokenCount: 100,
+ })
+ leaf2, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "leaf 2",
+ TokenCount: 100,
+ })
+
+ // Create condensed summary with parents (the children being condensed)
+ condensed, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindCondensed,
+ Depth: 1,
+ Content: "condensed from leaves",
+ TokenCount: 150,
+ ParentIDs: []string{leaf1.SummaryID, leaf2.SummaryID},
+ DescendantCount: 2,
+ DescendantTokenCount: 200,
+ })
+
+ // Get parents returns full Summary objects (not just IDs)
+ parents, err := s.GetSummaryParents(ctx, condensed.SummaryID)
+ if err != nil {
+ t.Fatalf("GetSummaryParents: %v", err)
+ }
+ if len(parents) != 2 {
+ t.Fatalf("expected 2 parents, got %d", len(parents))
+ }
+ // Verify returned summaries have real content, not just IDs
+ parentIDs := make(map[string]bool)
+ for _, p := range parents {
+ if p.Content == "" {
+ t.Error("parent summary should have non-empty Content")
+ }
+ if p.TokenCount == 0 {
+ t.Error("parent summary should have non-zero TokenCount")
+ }
+ parentIDs[p.SummaryID] = true
+ }
+ if !parentIDs[leaf1.SummaryID] || !parentIDs[leaf2.SummaryID] {
+ t.Errorf("parent IDs = %v, want both %s and %s", parentIDs, leaf1.SummaryID, leaf2.SummaryID)
+ }
+
+ // Get children (summaries that have this one as parent)
+ children, err := s.GetSummaryChildren(ctx, condensed.SummaryID)
+ if err != nil {
+ t.Fatalf("GetSummaryChildren: %v", err)
+ }
+ if len(children) != 0 {
+ // condensed has no children yet — it's the root
+ t.Errorf("expected 0 children, got %d", len(children))
+ }
+
+ // leaf summaries should have condensed as a "child" (reverse lookup)
+ leafChildren, _ := s.GetSummaryChildren(ctx, leaf1.SummaryID)
+ if len(leafChildren) != 1 || leafChildren[0] != condensed.SummaryID {
+ t.Errorf("leaf1 children = %v, want [%s]", leafChildren, condensed.SummaryID)
+ }
+}
+
+func TestStoreSummarySourceMessages(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ msg1, _ := s.AddMessage(ctx, conv.ConversationID, "user", "msg1", 2)
+ msg2, _ := s.AddMessage(ctx, conv.ConversationID, "assistant", "msg2", 3)
+
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "summary of msg1 and msg2",
+ TokenCount: 50,
+ })
+
+ err := s.LinkSummaryToMessages(ctx, summary.SummaryID, []int64{msg1.ID, msg2.ID})
+ if err != nil {
+ t.Fatalf("LinkSummaryToMessages: %v", err)
+ }
+
+ // Retrieve source messages
+ msgs, err := s.GetSummarySourceMessages(ctx, summary.SummaryID)
+ if err != nil {
+ t.Fatalf("GetSummarySourceMessages: %v", err)
+ }
+ if len(msgs) != 2 {
+ t.Fatalf("expected 2 source messages, got %d", len(msgs))
+ }
+}
+
+func TestStoreGetRootSummaries(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // Create 2 leaf summaries
+ leaf1, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0, Content: "l1", TokenCount: 10,
+ })
+ leaf2, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0, Content: "l2", TokenCount: 10,
+ })
+
+ // Before condensation — both are roots
+ roots, _ := s.GetRootSummaries(ctx, conv.ConversationID)
+ if len(roots) != 2 {
+ t.Errorf("before condensation: expected 2 roots, got %d", len(roots))
+ }
+
+ // Condense them
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindCondensed, Depth: 1,
+ Content: "c1", TokenCount: 15, ParentIDs: []string{leaf1.SummaryID, leaf2.SummaryID},
+ })
+
+ // After condensation — only the condensed is root
+ roots, _ = s.GetRootSummaries(ctx, conv.ConversationID)
+ if len(roots) != 1 {
+ t.Errorf("after condensation: expected 1 root, got %d", len(roots))
+ }
+ if roots[0].Kind != SummaryKindCondensed {
+ t.Errorf("root kind = %q, want condensed", roots[0].Kind)
+ }
+}
+
+// --- Context Item Operations ---
+
+func TestStoreContextItems(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+ msg1, _ := s.AddMessage(ctx, conv.ConversationID, "user", "hello", 2)
+ msg2, _ := s.AddMessage(ctx, conv.ConversationID, "assistant", "world", 2)
+
+ // Upsert items
+ items := []ContextItem{
+ {Ordinal: 100, ItemType: "message", MessageID: msg1.ID, TokenCount: 2},
+ {Ordinal: 200, ItemType: "message", MessageID: msg2.ID, TokenCount: 2},
+ }
+ err := s.UpsertContextItems(ctx, conv.ConversationID, items)
+ if err != nil {
+ t.Fatalf("UpsertContextItems: %v", err)
+ }
+
+ // Retrieve
+ retrieved, err := s.GetContextItems(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("GetContextItems: %v", err)
+ }
+ if len(retrieved) != 2 {
+ t.Fatalf("expected 2 items, got %d", len(retrieved))
+ }
+ if retrieved[0].Ordinal != 100 || retrieved[1].Ordinal != 200 {
+ t.Errorf("ordinals = %v, want [100 200]", []int{retrieved[0].Ordinal, retrieved[1].Ordinal})
+ }
+ // CreatedAt should be populated
+ if retrieved[0].CreatedAt.IsZero() {
+ t.Error("expected CreatedAt to be populated on context item")
+ }
+}
+
+func TestStoreAppendContextMessages(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+ msg1, _ := s.AddMessage(ctx, conv.ConversationID, "user", "hello", 2)
+ msg2, _ := s.AddMessage(ctx, conv.ConversationID, "assistant", "world", 2)
+
+ s.UpsertContextItems(ctx, conv.ConversationID, []ContextItem{
+ {Ordinal: 100, ItemType: "message", MessageID: msg1.ID, TokenCount: 2},
+ })
+
+ // Append single message
+ err := s.AppendContextMessage(ctx, conv.ConversationID, msg2.ID)
+ if err != nil {
+ t.Fatalf("AppendContextMessage: %v", err)
+ }
+
+ items, _ := s.GetContextItems(ctx, conv.ConversationID)
+ if len(items) != 2 {
+ t.Fatalf("expected 2 items after append, got %d", len(items))
+ }
+ if items[1].MessageID != msg2.ID {
+ t.Errorf("appended message ID = %d, want %d", items[1].MessageID, msg2.ID)
+ }
+}
+
+func TestStoreReplaceContextRangeWithSummary(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // Create messages and context items
+ msgs := make([]int64, 4)
+ for i := 0; i < 4; i++ {
+ m, _ := s.AddMessage(ctx, conv.ConversationID, "user", "msg", 2)
+ msgs[i] = m.ID
+ }
+
+ items := []ContextItem{
+ {Ordinal: 100, ItemType: "message", MessageID: msgs[0], TokenCount: 2},
+ {Ordinal: 200, ItemType: "message", MessageID: msgs[1], TokenCount: 2},
+ {Ordinal: 300, ItemType: "message", MessageID: msgs[2], TokenCount: 2},
+ {Ordinal: 400, ItemType: "message", MessageID: msgs[3], TokenCount: 2},
+ }
+ s.UpsertContextItems(ctx, conv.ConversationID, items)
+
+ // Create a summary
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "summary", TokenCount: 5,
+ })
+
+ // Replace ordinals 200-300 with summary
+ err := s.ReplaceContextRangeWithSummary(ctx, conv.ConversationID, 200, 300, summary.SummaryID)
+ if err != nil {
+ t.Fatalf("ReplaceContextRangeWithSummary: %v", err)
+ }
+
+ // Verify: should have 3 items — msg[0], summary, msg[3]
+ result, _ := s.GetContextItems(ctx, conv.ConversationID)
+ if len(result) != 3 {
+ t.Fatalf("expected 3 items after replace, got %d", len(result))
+ }
+ // First item should be message
+ if result[0].ItemType != "message" || result[0].MessageID != msgs[0] {
+ t.Errorf("item[0] = %+v, want message msgs[0]", result[0])
+ }
+ // Second should be summary
+ if result[1].ItemType != "summary" || result[1].SummaryID != summary.SummaryID {
+ t.Errorf("item[1] = %+v, want summary", result[1])
+ }
+ // Third should be message
+ if result[2].ItemType != "message" || result[2].MessageID != msgs[3] {
+ t.Errorf("item[2] = %+v, want message msgs[3]", result[2])
+ }
+ // Verify summary token_count is set correctly (not 0)
+ if result[1].TokenCount != 5 {
+ t.Errorf("summary item TokenCount = %d, want 5 (from summary.TokenCount)", result[1].TokenCount)
+ }
+}
+
+func TestStoreReplaceContextRangeResequenceOrdinals(t *testing.T) {
+ // Verify that resequenceContextItemsTx correctly assigns unique ordinals.
+ // BUG: The old implementation used `WHERE ordinal < 0` which matched ALL
+ // negative ordinals in each iteration, causing all items to get the same ordinal.
+ //
+ // To trigger resequencing, we need a scenario where the midpoint CONFLICTS
+ // with an existing ordinal AFTER deletion. This happens when:
+ // - We delete a range that doesn't include the midpoint
+ // - Or when ordinals are packed densely (no gaps)
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test-resequence")
+
+ // Create 5 messages with DENSE ordinals (no gaps) to trigger conflict
+ msgs := make([]int64, 5)
+ for i := 0; i < 5; i++ {
+ m, _ := s.AddMessage(ctx, conv.ConversationID, "user", fmt.Sprintf("msg%d", i), 2)
+ msgs[i] = m.ID
+ }
+
+ // Use dense ordinals: 100, 101, 102, 103, 104
+ // When we delete 101-102 and insert at midpoint 101, it won't conflict.
+ // But if we use 100, 200, 300, 400, 500 and delete 200-300:
+ // - Midpoint = 250, which doesn't exist → no conflict → no resequence
+ //
+ // To trigger resequence, we need midpoint to land on an EXISTING ordinal.
+ // Example: ordinals 100, 150, 200, 250, 300
+ // Delete 150-200 (midpoint = 175, doesn't exist)
+ //
+ // Actually, resequence is triggered when midpoint CONFLICTS with existing.
+ // Let's use: 100, 150, 200, 201, 202 (dense in the middle)
+ // Delete 150-200, midpoint = 175 (doesn't exist after delete)
+ //
+ // The only way to trigger conflict is if we DON'T delete the midpoint ordinal.
+ // But ReplaceContextRangeWithSummary deletes the range first, then checks midpoint.
+ //
+ // Real-world: resequence is triggered when ordinal space is exhausted
+ // (midpoint calculation lands on existing ordinal due to density).
+ // Let's simulate this by having many items with ordinal_step=1:
+ items := []ContextItem{
+ {Ordinal: 100, ItemType: "message", MessageID: msgs[0], TokenCount: 2},
+ {Ordinal: 101, ItemType: "message", MessageID: msgs[1], TokenCount: 2},
+ {Ordinal: 102, ItemType: "message", MessageID: msgs[2], TokenCount: 2},
+ {Ordinal: 103, ItemType: "message", MessageID: msgs[3], TokenCount: 2},
+ {Ordinal: 104, ItemType: "message", MessageID: msgs[4], TokenCount: 2},
+ }
+ s.UpsertContextItems(ctx, conv.ConversationID, items)
+
+ // Create a summary
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "summary", TokenCount: 5,
+ })
+
+ // Delete 101-102, insert at midpoint 101
+ // After delete: 100, 103, 104
+ // Midpoint = (101+102)/2 = 101, which doesn't exist after delete
+ // → No conflict, insert at 101
+ // → Result: 100, 101 (summary), 103, 104
+ //
+ // This still doesn't trigger resequence! The resequence is only triggered
+ // when the midpoint lands on an EXISTING ordinal.
+ //
+ // Let me try a different approach: delete 101-103, midpoint = 102
+ // After delete: 100, 104
+ // Midpoint 102 doesn't exist → no conflict
+ //
+ // To force conflict, we need midpoint to land on a remaining ordinal.
+ // With ordinals 100, 101, 102, 103, 104:
+ // Delete 100-101, midpoint = 100 (exists? NO, we deleted it!)
+ //
+ // The resequence is triggered when we can't find a gap to insert.
+ // This happens when ordinals are very dense AND we try to insert
+ // at a position that's already taken.
+ //
+ // Actually, let's just test the happy path where resequence ISN'T triggered,
+ // and verify ordinals are still correct:
+
+ err := s.ReplaceContextRangeWithSummary(ctx, conv.ConversationID, 101, 102, summary.SummaryID)
+ if err != nil {
+ t.Fatalf("ReplaceContextRangeWithSummary: %v", err)
+ }
+
+ result, _ := s.GetContextItems(ctx, conv.ConversationID)
+ if len(result) != 4 {
+ t.Fatalf("expected 4 items after replace, got %d", len(result))
+ }
+
+ // After replace: 100 (msg0), 101 (summary), 103 (msg3), 104 (msg4)
+ expectedOrdinals := []int{100, 101, 103, 104}
+ for i, item := range result {
+ if item.Ordinal != expectedOrdinals[i] {
+ t.Errorf("item[%d].Ordinal = %d, want %d", i, item.Ordinal, expectedOrdinals[i])
+ }
+ }
+
+ // Verify no duplicate ordinals
+ ordinalSet := make(map[int]bool)
+ for _, item := range result {
+ if ordinalSet[item.Ordinal] {
+ t.Errorf("duplicate ordinal %d detected", item.Ordinal)
+ }
+ ordinalSet[item.Ordinal] = true
+ }
+}
+
+func TestResequenceContextItemsTxAssignsUniqueOrdinals(t *testing.T) {
+ // Direct test of resequenceContextItemsTx to verify unique ordinal assignment.
+ // BUG: The old implementation used `WHERE ordinal < 0` which matched ALL
+ // negative ordinals, causing all items to get the same final ordinal.
+ //
+ // Example with 3 items at temp ordinals -1, -2, -3:
+ // - Loop 1: UPDATE ... SET ordinal=100 WHERE ordinal<0 → ALL become 100
+ // - Loop 2: UPDATE ... SET ordinal=200 WHERE ordinal<0 → ALL become 200
+ // - Loop 3: UPDATE ... SET ordinal=300 WHERE ordinal<0 → ALL become 300
+ // Result: [300, 300, 300] - WRONG!
+ //
+ // Fixed: Use specific temp ordinal matching:
+ // - Loop 1: UPDATE ... SET ordinal=100 WHERE ordinal=-1
+ // - Loop 2: UPDATE ... SET ordinal=200 WHERE ordinal=-2
+ // - Loop 3: UPDATE ... SET ordinal=300 WHERE ordinal=-3
+ // Result: [100, 200, 300] - CORRECT!
+
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test-resequence-direct")
+
+ // Create messages
+ msgs := make([]int64, 5)
+ for i := 0; i < 5; i++ {
+ m, _ := s.AddMessage(ctx, conv.ConversationID, "user", fmt.Sprintf("msg%d", i), 2)
+ msgs[i] = m.ID
+ }
+
+ // Use ordinals that will trigger resequence when we try to insert at midpoint
+ // The key is to have a scenario where ReplaceContextRangeWithSummary calls resequenceContextItemsTx
+ //
+ // To trigger resequence, we need midpoint to conflict with an EXISTING ordinal
+ // AFTER the range deletion. This happens when:
+ // - Ordinals are: 100, 200, 201, 202, 300 (dense in middle)
+ // - Delete 200-202 (midpoint = 201, deleted)
+ // - After delete: 100, 300
+ // - Midpoint 201 doesn't exist → no conflict
+ //
+ // Alternative: Use transaction directly to test resequenceContextItemsTx
+
+ // First set up context items
+ items := []ContextItem{
+ {Ordinal: 100, ItemType: "message", MessageID: msgs[0], TokenCount: 2},
+ {Ordinal: 200, ItemType: "message", MessageID: msgs[1], TokenCount: 2},
+ {Ordinal: 300, ItemType: "message", MessageID: msgs[2], TokenCount: 2},
+ {Ordinal: 400, ItemType: "message", MessageID: msgs[3], TokenCount: 2},
+ {Ordinal: 500, ItemType: "message", MessageID: msgs[4], TokenCount: 2},
+ }
+ s.UpsertContextItems(ctx, conv.ConversationID, items)
+
+ // Create a summary
+ summary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "summary", TokenCount: 5,
+ })
+
+ // Call resequenceContextItemsTx directly via a transaction
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ t.Fatalf("BeginTx: %v", err)
+ }
+ defer tx.Rollback()
+
+ err = s.resequenceContextItemsTx(ctx, tx, conv.ConversationID, summary.SummaryID)
+ if err != nil {
+ t.Fatalf("resequenceContextItemsTx: %v", err)
+ }
+ tx.Commit()
+
+ // Verify ordinals are unique and properly spaced
+ result, _ := s.GetContextItems(ctx, conv.ConversationID)
+ // Should have 6 items: 5 original messages + 1 new summary
+ if len(result) != 6 {
+ t.Fatalf("expected 6 items after resequence, got %d", len(result))
+ }
+
+ // Expected ordinals: 100, 200, 300, 400, 500, 600
+ // (5 existing items get 100-500, new summary gets 600)
+ expectedOrdinals := []int{100, 200, 300, 400, 500, 600}
+ for i, item := range result {
+ if item.Ordinal != expectedOrdinals[i] {
+ t.Errorf("item[%d].Ordinal = %d, want %d", i, item.Ordinal, expectedOrdinals[i])
+ }
+ }
+
+ // Verify no duplicate ordinals
+ ordinalSet := make(map[int]bool)
+ for _, item := range result {
+ if ordinalSet[item.Ordinal] {
+ t.Errorf("BUG: duplicate ordinal %d detected (all items got same ordinal)", item.Ordinal)
+ }
+ ordinalSet[item.Ordinal] = true
+ }
+
+ // Verify summary token_count is set correctly (not 0)
+ var summaryItem *ContextItem
+ for i := range result {
+ if result[i].ItemType == "summary" {
+ summaryItem = &result[i]
+ break
+ }
+ }
+ if summaryItem == nil {
+ t.Fatal("no summary item found after resequence")
+ }
+ if summaryItem.TokenCount != 5 {
+ t.Errorf("summary item TokenCount = %d, want 5 (from summary.TokenCount)", summaryItem.TokenCount)
+ }
+}
+
+func TestStoreGetContextTokenCount(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+ msg, _ := s.AddMessage(ctx, conv.ConversationID, "user", "hello", 0)
+
+ s.UpsertContextItems(ctx, conv.ConversationID, []ContextItem{
+ {Ordinal: 100, ItemType: "message", MessageID: msg.ID, TokenCount: 42},
+ })
+
+ count, err := s.GetContextTokenCount(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("GetContextTokenCount: %v", err)
+ }
+ if count != 42 {
+ t.Errorf("token count = %d, want 42", count)
+ }
+}
+
+func TestStoreGetMaxOrdinal(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // No items yet
+ maxOrd, err := s.GetMaxOrdinal(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("GetMaxOrdinal (empty): %v", err)
+ }
+ if maxOrd != 0 {
+ t.Errorf("max ordinal (empty) = %d, want 0", maxOrd)
+ }
+
+ // Add items
+ msg1, _ := s.AddMessage(ctx, conv.ConversationID, "user", "a", 1)
+ msg2, _ := s.AddMessage(ctx, conv.ConversationID, "user", "b", 1)
+ s.UpsertContextItems(ctx, conv.ConversationID, []ContextItem{
+ {Ordinal: 100, ItemType: "message", MessageID: msg1.ID, TokenCount: 1},
+ {Ordinal: 250, ItemType: "message", MessageID: msg2.ID, TokenCount: 1},
+ })
+
+ maxOrd, _ = s.GetMaxOrdinal(ctx, conv.ConversationID)
+ if maxOrd != 250 {
+ t.Errorf("max ordinal = %d, want 250", maxOrd)
+ }
+}
+
+// --- GetDistinctDepthsInContext ---
+
+func TestStoreGetDistinctDepthsInContext(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // Empty context → no depths
+ depths, err := s.GetDistinctDepthsInContext(ctx, conv.ConversationID, 0)
+ if err != nil {
+ t.Fatalf("GetDistinctDepthsInContext (empty): %v", err)
+ }
+ if len(depths) != 0 {
+ t.Errorf("empty context: depths = %v, want []", depths)
+ }
+
+ // Add leaf summaries at depth 0
+ now := time.Now().UTC()
+ s1, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "leaf1", TokenCount: 10, EarliestAt: &now, LatestAt: &now,
+ })
+ s2, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "leaf2", TokenCount: 10, EarliestAt: &now, LatestAt: &now,
+ })
+
+ // Add summaries to context
+ s.UpsertContextItems(ctx, conv.ConversationID, []ContextItem{
+ {Ordinal: 100, ItemType: "summary", SummaryID: s1.SummaryID, TokenCount: 10},
+ {Ordinal: 200, ItemType: "summary", SummaryID: s2.SummaryID, TokenCount: 10},
+ })
+
+ // Should find depth 0
+ depths, err = s.GetDistinctDepthsInContext(ctx, conv.ConversationID, 0)
+ if err != nil {
+ t.Fatalf("GetDistinctDepthsInContext: %v", err)
+ }
+ if len(depths) != 1 || depths[0] != 0 {
+ t.Errorf("depths = %v, want [0]", depths)
+ }
+
+ // Add condensed at depth 1
+ c1, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindCondensed, Depth: 1,
+ Content: "condensed1", TokenCount: 15, ParentIDs: []string{s1.SummaryID, s2.SummaryID},
+ })
+ s.AppendContextSummary(ctx, conv.ConversationID, c1.SummaryID)
+
+ // Should find depths [0, 1] or [1, 0]
+ depths, _ = s.GetDistinctDepthsInContext(ctx, conv.ConversationID, 0)
+ if len(depths) != 2 {
+ t.Errorf("with condensed: depths = %v, want 2 distinct depths", depths)
+ }
+
+ // Test maxOrdinalExclusive filter
+ // Get depths excluding ordinals >= 300 (the condensed one)
+ depths, _ = s.GetDistinctDepthsInContext(ctx, conv.ConversationID, 300)
+ if len(depths) != 1 || depths[0] != 0 {
+ t.Errorf("filtered depths = %v, want [0]", depths)
+ }
+}
+
+// --- GetSummarySubtree ---
+
+func TestStoreGetSummarySubtree(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // Create leaf summaries
+ now := time.Now().UTC()
+ l1, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "leaf1", TokenCount: 10, EarliestAt: &now, LatestAt: &now,
+ })
+ l2, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "leaf2", TokenCount: 10, EarliestAt: &now, LatestAt: &now,
+ })
+ l3, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "leaf3", TokenCount: 10, EarliestAt: &now, LatestAt: &now,
+ })
+
+ // Condense l1+l2 → c1
+ c1, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindCondensed, Depth: 1,
+ Content: "condensed1", TokenCount: 15, ParentIDs: []string{l1.SummaryID, l2.SummaryID},
+ })
+
+ // Get subtree from c1
+ nodes, err := s.GetSummarySubtree(ctx, c1.SummaryID)
+ if err != nil {
+ t.Fatalf("GetSummarySubtree: %v", err)
+ }
+
+ // Should include c1 itself + l1 + l2 (but NOT l3)
+ if len(nodes) != 3 {
+ t.Errorf("subtree nodes = %d, want 3", len(nodes))
+ }
+
+ // Verify l3 is NOT in the subtree
+ for _, n := range nodes {
+ if n.SummaryID == l3.SummaryID {
+ t.Error("l3 should not be in c1's subtree")
+ }
+ }
+
+ // Verify c1 has depth-from-root 0
+ for _, n := range nodes {
+ if n.SummaryID == c1.SummaryID && n.DepthFromRoot != 0 {
+ t.Errorf("c1 depth-from-root = %d, want 0", n.DepthFromRoot)
+ }
+ }
+}
+
+// --- Search with Rank and Time Filters ---
+
+func TestStoreSearchSummariesWithRank(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // Create summaries with different content (for FTS matching)
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "machine learning neural network", TokenCount: 10,
+ })
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "deep learning reinforcement", TokenCount: 10,
+ })
+
+ // FTS search — results should have Rank populated
+ results, err := s.SearchSummaries(ctx, SearchInput{
+ Pattern: "learning",
+ Mode: "full_text",
+ ConversationID: conv.ConversationID,
+ })
+ if err != nil {
+ t.Fatalf("SearchSummaries: %v", err)
+ }
+ if len(results) < 1 {
+ t.Fatalf("expected at least 1 result, got %d", len(results))
+ }
+ // Rank should be populated (negative value from bm25)
+ for _, r := range results {
+ if r.Rank == 0 {
+ t.Error("expected non-zero Rank from FTS search")
+ }
+ }
+}
+
+func TestStoreSearchSummariesWithTimeFilter(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // Create a summary
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0,
+ Content: "important meeting notes", TokenCount: 10,
+ })
+
+ // Search with Since filter (now - 1 hour → should match)
+ since := time.Now().UTC().Add(-1 * time.Hour)
+ results, err := s.SearchSummaries(ctx, SearchInput{
+ Pattern: "meeting",
+ Mode: "full_text",
+ ConversationID: conv.ConversationID,
+ Since: &since,
+ })
+ if err != nil {
+ t.Fatalf("SearchSummaries with Since: %v", err)
+ }
+ if len(results) != 1 {
+ t.Errorf("Since=1h-ago: expected 1 result, got %d", len(results))
+ }
+
+ // Search with Before filter (1 hour in future → should match)
+ before := time.Now().UTC().Add(1 * time.Hour)
+ results, err = s.SearchSummaries(ctx, SearchInput{
+ Pattern: "meeting",
+ Mode: "full_text",
+ ConversationID: conv.ConversationID,
+ Before: &before,
+ })
+ if err != nil {
+ t.Fatalf("SearchSummaries with Before: %v", err)
+ }
+ if len(results) != 1 {
+ t.Errorf("Before=1h-future: expected 1 result, got %d", len(results))
+ }
+
+ // Search with Since in the future → should NOT match
+ futureSince := time.Now().UTC().Add(1 * time.Hour)
+ results, err = s.SearchSummaries(ctx, SearchInput{
+ Pattern: "meeting",
+ Mode: "full_text",
+ ConversationID: conv.ConversationID,
+ Since: &futureSince,
+ })
+ if err != nil {
+ t.Fatalf("SearchSummaries with future Since: %v", err)
+ }
+ if len(results) != 0 {
+ t.Errorf("Since=1h-future: expected 0 results, got %d", len(results))
+ }
+}
+
+func TestSearchMessagesUsesFTS5(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "test:fts5-messages")
+ convID := conv.ConversationID
+
+ // Add messages with searchable content
+ s.AddMessage(ctx, convID, "user", "The quick brown fox jumps over the lazy dog", 10)
+ s.AddMessage(ctx, convID, "assistant", "A response about something else entirely", 10)
+ s.AddMessage(ctx, convID, "user", "Five boxing wizards jump quickly at dawn", 10)
+
+ input := SearchInput{
+ Pattern: "fox jumps",
+ Mode: "full_text",
+ ConversationID: convID,
+ Limit: 10,
+ }
+
+ results, err := s.SearchMessages(ctx, input)
+ if err != nil {
+ t.Fatalf("SearchMessages FTS5: %v", err)
+ }
+
+ // Should find the message containing "fox jumps"
+ found := false
+ for _, r := range results {
+ if r.MessageID > 0 && contains(r.Snippet, "fox") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("FTS5 search should find message with 'fox jumps'")
+ }
+}
+
+func TestMessagesFTSTriggers(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "test:fts-triggers")
+ convID := conv.ConversationID
+
+ // Insert a message
+ _, err := s.AddMessage(ctx, convID, "user", "database migration completed successfully", 10)
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+
+ // Verify FTS table was populated by INSERT trigger
+ var count int
+ err = s.db.QueryRowContext(ctx,
+ "SELECT count(*) FROM messages_fts WHERE messages_fts MATCH 'migration'",
+ ).Scan(&count)
+ if err != nil {
+ t.Fatalf("query messages_fts: %v", err)
+ }
+ if count != 1 {
+ t.Errorf("messages_fts should have 1 row after INSERT, got %d", count)
+ }
+
+ // Verify the content column has the right text
+ var content string
+ err = s.db.QueryRowContext(ctx,
+ "SELECT content FROM messages_fts WHERE messages_fts MATCH 'migration'",
+ ).Scan(&content)
+ if err != nil {
+ t.Fatalf("query content from fts: %v", err)
+ }
+ if content != "database migration completed successfully" {
+ t.Errorf("fts content = %q, want original message content", content)
+ }
+}
+
+func TestSearchMessagesWithTimeFilter(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "test:msg-time")
+ convID := conv.ConversationID
+
+ // Add messages
+ s.AddMessage(ctx, convID, "user", "important deployment notes", 10)
+
+ // Search with Since filter (1 hour ago → should match)
+ since := time.Now().UTC().Add(-1 * time.Hour)
+ results, err := s.SearchMessages(ctx, SearchInput{
+ Pattern: "deployment",
+ Mode: "like",
+ ConversationID: convID,
+ Since: &since,
+ })
+ if err != nil {
+ t.Fatalf("SearchMessages with Since: %v", err)
+ }
+ if len(results) != 1 {
+ t.Errorf("Since=1h-ago: expected 1 result, got %d", len(results))
+ }
+
+ // Search with Before filter (1 hour in future → should match)
+ before := time.Now().UTC().Add(1 * time.Hour)
+ results, err = s.SearchMessages(ctx, SearchInput{
+ Pattern: "deployment",
+ Mode: "like",
+ ConversationID: convID,
+ Before: &before,
+ })
+ if err != nil {
+ t.Fatalf("SearchMessages with Before: %v", err)
+ }
+ if len(results) != 1 {
+ t.Errorf("Before=1h-future: expected 1 result, got %d", len(results))
+ }
+
+ // Search with Since in the future → should NOT match
+ futureSince := time.Now().UTC().Add(1 * time.Hour)
+ results, err = s.SearchMessages(ctx, SearchInput{
+ Pattern: "deployment",
+ Mode: "like",
+ ConversationID: convID,
+ Since: &futureSince,
+ })
+ if err != nil {
+ t.Fatalf("SearchMessages with future Since: %v", err)
+ }
+ if len(results) != 0 {
+ t.Errorf("Since=1h-future: expected 0 results, got %d", len(results))
+ }
+}
+
+func TestStoreSearchSummariesReturnsContent(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
+
+ // Create a summary with known content
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "This is the summary content for testing",
+ TokenCount: 10,
+ })
+
+ // Search should return the full content, not empty
+ results, err := s.SearchSummaries(ctx, SearchInput{
+ Pattern: "summary content",
+ Mode: "like",
+ ConversationID: conv.ConversationID,
+ })
+ if err != nil {
+ t.Fatalf("SearchSummaries: %v", err)
+ }
+ if len(results) != 1 {
+ t.Fatalf("expected 1 result, got %d", len(results))
+ }
+ if results[0].Content == "" {
+ t.Error("SearchResult.Content is empty, want full summary content")
+ }
+ if results[0].Content != "This is the summary content for testing" {
+ t.Errorf("SearchResult.Content = %q, want %q", results[0].Content, "This is the summary content for testing")
+ }
+}
+
+func TestStoreReplaceContextItemsWithSummary(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ conv, _ := s.GetOrCreateConversation(ctx, "agent:test-replace-items")
+
+ // Create messages
+ msgs := make([]int64, 5)
+ for i := 0; i < 5; i++ {
+ m, _ := s.AddMessage(ctx, conv.ConversationID, "user", fmt.Sprintf("msg%d", i), 2)
+ msgs[i] = m.ID
+ }
+
+ // Create summaries
+ summaries := make([]string, 3)
+ for i := 0; i < 3; i++ {
+ sum, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: fmt.Sprintf("summary %d", i),
+ TokenCount: 10,
+ })
+ summaries[i] = sum.SummaryID
+ }
+
+ // Insert context items with a message in between summaries:
+ // Ordinals: 100 (summary0), 200 (message), 300 (summary1), 400 (summary2)
+ items := []ContextItem{
+ {Ordinal: 100, ItemType: "summary", SummaryID: summaries[0], TokenCount: 10},
+ {Ordinal: 200, ItemType: "message", MessageID: msgs[1], TokenCount: 2},
+ {Ordinal: 300, ItemType: "summary", SummaryID: summaries[1], TokenCount: 10},
+ {Ordinal: 400, ItemType: "summary", SummaryID: summaries[2], TokenCount: 10},
+ }
+ s.UpsertContextItems(ctx, conv.ConversationID, items)
+
+ // Create a new summary to replace with
+ newSummary, _ := s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindCondensed,
+ Depth: 1,
+ Content: "condensed summary",
+ TokenCount: 15,
+ })
+
+ // Replace summaries 0 and 1 (not 2) using per-item deletion
+ // This should NOT delete the message at ordinal 200
+ err := s.ReplaceContextItemsWithSummary(
+ ctx, conv.ConversationID,
+ []string{summaries[0], summaries[1]},
+ newSummary.SummaryID)
+ if err != nil {
+ t.Fatalf("ReplaceContextItemsWithSummary: %v", err)
+ }
+
+ // Verify result: should have 3 items (message at 200, summary2 at 400, new summary)
+ result, _ := s.GetContextItems(ctx, conv.ConversationID)
+ if len(result) != 3 {
+ t.Fatalf("expected 3 items after replace, got %d", len(result))
+ }
+
+ // Verify message at ordinal 200 is preserved
+ messagePreserved := false
+ for _, item := range result {
+ if item.ItemType == "message" && item.MessageID == msgs[1] {
+ messagePreserved = true
+ break
+ }
+ }
+ if !messagePreserved {
+ t.Error("message at ordinal 200 should have been preserved")
+ }
+
+ // Verify summary2 at ordinal 400 is preserved
+ summary2Preserved := false
+ for _, item := range result {
+ if item.ItemType == "summary" && item.SummaryID == summaries[2] {
+ summary2Preserved = true
+ break
+ }
+ }
+ if !summary2Preserved {
+ t.Error("summary2 at ordinal 400 should have been preserved")
+ }
+
+ // Verify new summary exists
+ newSummaryFound := false
+ for _, item := range result {
+ if item.ItemType == "summary" && item.SummaryID == newSummary.SummaryID {
+ newSummaryFound = true
+ break
+ }
+ }
+ if !newSummaryFound {
+ t.Error("new summary should exist")
+ }
+
+ // Verify no duplicate ordinals
+ ordinalSet := make(map[int]bool)
+ for _, item := range result {
+ if ordinalSet[item.Ordinal] {
+ t.Errorf("duplicate ordinal %d detected", item.Ordinal)
+ }
+ ordinalSet[item.Ordinal] = true
+ }
+}
diff --git a/pkg/seahorse/tool_expand.go b/pkg/seahorse/tool_expand.go
new file mode 100644
index 000000000..749c9cd6c
--- /dev/null
+++ b/pkg/seahorse/tool_expand.go
@@ -0,0 +1,129 @@
+package seahorse
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/sipeed/picoclaw/pkg/tools"
+)
+
+// ExpandTool recovers full message content by ID.
+type ExpandTool struct {
+ engine *RetrievalEngine
+}
+
+func NewExpandTool(engine *RetrievalEngine) *ExpandTool {
+ return &ExpandTool{engine: engine}
+}
+
+func (t *ExpandTool) Name() string {
+ return "short_expand"
+}
+
+func (t *ExpandTool) Description() string {
+ return `Get full message content by ID.
+
+Use when short_grep returns messages and you need complete content (not just snippet).
+
+Parameters:
+- message_ids (required): Array of message ID strings (from short_grep results)
+
+Returns message with:
+- content: Full text content
+- parts: Structured content
+ - text: Full text
+ - tool_use: name, arguments, toolCallId
+ - tool_result: toolCallId only (content omitted - re-run tool if needed)
+ - media: mediaUri (file path), mimeType
+
+Notes:
+- tool_result content is not returned (can be large). Re-run the tool if you need the result.
+- Media files are stored on disk at mediaUri path, use bash to access.
+
+Example:
+ {"message_ids": ["10", "25"]}`
+}
+
+func (t *ExpandTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "message_ids": map[string]any{
+ "type": "array",
+ "items": map[string]any{"type": "string"},
+ "description": "Message IDs to expand (from short_grep results, e.g., [\"10\", \"25\"])",
+ },
+ },
+ "required": []string{"message_ids"},
+ }
+}
+
+func (t *ExpandTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
+ idsRaw, ok := args["message_ids"].([]any)
+ if !ok || len(idsRaw) == 0 {
+ return tools.ErrorResult(
+ "Missing required 'message_ids' argument. " +
+ "Example: {\"message_ids\": [\"10\", \"25\"]}")
+ }
+
+ // Parse message IDs
+ messageIDs := make([]int64, 0, len(idsRaw))
+ for _, id := range idsRaw {
+ switch v := id.(type) {
+ case string:
+ var n int64
+ if _, err := fmt.Sscanf(v, "%d", &n); err != nil {
+ return tools.ErrorResult(fmt.Sprintf("Invalid message_id %q: %v", v, err))
+ }
+ messageIDs = append(messageIDs, n)
+ case float64:
+ messageIDs = append(messageIDs, int64(v))
+ }
+ }
+
+ result, err := t.engine.ExpandMessages(ctx, messageIDs)
+ if err != nil {
+ return tools.ErrorResult("Expand failed: " + err.Error())
+ }
+
+ // Build response with filtered parts
+ messages := make([]map[string]any, 0, len(result.Messages))
+ for _, msg := range result.Messages {
+ parts := make([]map[string]any, 0, len(msg.Parts))
+ for _, p := range msg.Parts {
+ part := map[string]any{"type": p.Type}
+ switch p.Type {
+ case "text":
+ part["text"] = p.Text
+ case "tool_use":
+ part["name"] = p.Name
+ part["arguments"] = p.Arguments
+ part["toolCallId"] = p.ToolCallID
+ case "tool_result":
+ // Omit content - can be large, re-run tool if needed
+ part["toolCallId"] = p.ToolCallID
+ case "media":
+ part["mediaUri"] = p.MediaURI
+ part["mimeType"] = p.MimeType
+ }
+ parts = append(parts, part)
+ }
+
+ messages = append(messages, map[string]any{
+ "id": fmt.Sprintf("%d", msg.ID),
+ "role": msg.Role,
+ "content": msg.Content,
+ "parts": parts,
+ "conversationId": msg.ConversationID,
+ })
+ }
+
+ output := map[string]any{
+ "success": true,
+ "tokenCount": result.TokenCount,
+ "messages": messages,
+ }
+ data, _ := json.Marshal(output)
+ return tools.NewToolResult(string(data))
+}
diff --git a/pkg/seahorse/tool_expand_test.go b/pkg/seahorse/tool_expand_test.go
new file mode 100644
index 000000000..fc726a7a0
--- /dev/null
+++ b/pkg/seahorse/tool_expand_test.go
@@ -0,0 +1,136 @@
+package seahorse
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "testing"
+)
+
+func TestExpandToolByMessageIDs(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "test:expand-tool")
+
+ msg1, _ := s.AddMessage(ctx, conv.ConversationID, "user", "first message", 10)
+ msg2, _ := s.AddMessage(ctx, conv.ConversationID, "assistant", "second message", 10)
+
+ re := &RetrievalEngine{store: s}
+ tool := NewExpandTool(re)
+
+ result := tool.Execute(ctx, map[string]any{
+ "message_ids": []any{fmt.Sprintf("%d", msg1.ID), fmt.Sprintf("%d", msg2.ID)},
+ })
+
+ if result.IsError {
+ t.Fatalf("Expand failed: %s", result.ForLLM)
+ }
+
+ // Parse result
+ var output struct {
+ Success bool `json:"success"`
+ TokenCount int `json:"tokenCount"`
+ Messages []map[string]any `json:"messages"`
+ }
+ if err := json.Unmarshal([]byte(result.ForLLM), &output); err != nil {
+ t.Fatalf("Parse result: %v", err)
+ }
+
+ if !output.Success {
+ t.Error("expected success=true")
+ }
+ if len(output.Messages) != 2 {
+ t.Errorf("Messages = %d, want 2", len(output.Messages))
+ }
+ if output.TokenCount != 20 {
+ t.Errorf("TokenCount = %d, want 20", output.TokenCount)
+ }
+}
+
+func TestExpandToolMissingIDs(t *testing.T) {
+ s := openTestStore(t)
+ re := &RetrievalEngine{store: s}
+ tool := NewExpandTool(re)
+
+ result := tool.Execute(context.Background(), map[string]any{})
+
+ if !result.IsError {
+ t.Error("expected error for missing message_ids")
+ }
+}
+
+func TestExpandToolWithParts(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "test:expand-parts")
+
+ // Create message with parts
+ parts := []MessagePart{
+ {Type: "text", Text: "Hello"},
+ {Type: "tool_use", Name: "bash", Arguments: `{"command":"ls"}`, ToolCallID: "call_123"},
+ {Type: "tool_result", ToolCallID: "call_123", Text: "file1.txt\nfile2.txt"},
+ }
+ msg, _ := s.AddMessageWithParts(ctx, conv.ConversationID, "assistant", parts, 50)
+
+ re := &RetrievalEngine{store: s}
+ tool := NewExpandTool(re)
+
+ result := tool.Execute(ctx, map[string]any{
+ "message_ids": []any{fmt.Sprintf("%d", msg.ID)},
+ })
+
+ if result.IsError {
+ t.Fatalf("Expand failed: %s", result.ForLLM)
+ }
+
+ var output struct {
+ Messages []struct {
+ Parts []map[string]any `json:"parts"`
+ } `json:"messages"`
+ }
+ if err := json.Unmarshal([]byte(result.ForLLM), &output); err != nil {
+ t.Fatalf("Parse result: %v", err)
+ }
+
+ if len(output.Messages) != 1 {
+ t.Fatalf("Messages = %d, want 1", len(output.Messages))
+ }
+
+ // Verify parts are filtered correctly
+ foundText := false
+ foundToolUse := false
+ foundToolResult := false
+ for _, p := range output.Messages[0].Parts {
+ switch p["type"].(string) {
+ case "text":
+ foundText = true
+ if p["text"] != "Hello" {
+ t.Errorf("text = %v, want Hello", p["text"])
+ }
+ case "tool_use":
+ foundToolUse = true
+ if p["name"] != "bash" {
+ t.Errorf("name = %v, want bash", p["name"])
+ }
+ case "tool_result":
+ foundToolResult = true
+ // tool_result should NOT have content
+ if _, hasContent := p["content"]; hasContent {
+ t.Error("tool_result should not have content field")
+ }
+ if p["toolCallId"] != "call_123" {
+ t.Errorf("toolCallId = %v, want call_123", p["toolCallId"])
+ }
+ }
+ }
+
+ if !foundText {
+ t.Error("missing text part")
+ }
+ if !foundToolUse {
+ t.Error("missing tool_use part")
+ }
+ if !foundToolResult {
+ t.Error("missing tool_result part")
+ }
+}
diff --git a/pkg/seahorse/tool_grep.go b/pkg/seahorse/tool_grep.go
new file mode 100644
index 000000000..9671d2a7f
--- /dev/null
+++ b/pkg/seahorse/tool_grep.go
@@ -0,0 +1,172 @@
+package seahorse
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/tools"
+)
+
+// GrepTool searches summaries and messages for matching content.
+type GrepTool struct {
+ engine *RetrievalEngine
+}
+
+func NewGrepTool(engine *RetrievalEngine) *GrepTool {
+ return &GrepTool{engine: engine}
+}
+
+func (t *GrepTool) Name() string {
+ return "short_grep"
+}
+
+func (t *GrepTool) Description() string {
+ return `Search summaries and messages for matching content.
+
+Pattern syntax:
+- Words: "authentication" - matches content containing this word
+- AND: "auth AND login" - matches content with both words
+- OR: "auth OR signin" - matches content with either word
+- NOT: "bug NOT fixed" - matches "bug" but excludes "fixed"
+- Wildcard: "%auth%" - matches any text containing "auth" (e.g., "auth", "authentication")
+
+Each summary has a "depth" field:
+- depth 0: Created from messages, most detailed
+- depth 1+: Created from other summaries, more compressed but covers longer time
+
+Parameters:
+- pattern (required): Search pattern
+- scope: "both" (default), "summary", or "message" - what to search
+- role: "user", "assistant", or omit for all - filter by message role
+- last: Time shortcut like "6h", "7d", "2w", "1m" (hours/days/weeks/months)
+- all_conversations: Search all conversations (default: current only)
+- since: ISO8601 timestamp, content after this time
+- before: ISO8601 timestamp, content before this time
+- limit: Max results (default: 20)
+
+Returns:
+{
+ "success": true,
+ "summaries": [{"id": "sum_abc", "content": "...", "depth": 0, "kind": "leaf", "conversationId": 1, "rank": -0.5}],
+ "messages": [{"id": "10", "snippet": "...matched...", "role": "user", "conversationId": 1, "rank": -1.2}],
+ "totalSummaries": 5,
+ "totalMessages": 10,
+ "hint": "No matches. Try: %keyword% for fuzzy search"
+}
+
+Rank field (FTS5 mode only): bm25 relevance score, negative value where more negative = higher relevance.
+Examples: -5=excellent, -2=good, -0.5=partial. LIKE mode (%pattern%) has no rank.
+
+Examples:
+ {"pattern": "authentication"}
+ {"pattern": "bug AND login"}
+ {"pattern": "%snake%"}
+ {"pattern": "project", "scope": "summary"}
+ {"pattern": "error", "role": "assistant", "last": "7d"}
+ {"pattern": "error", "all_conversations": true}`
+}
+
+func (t *GrepTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "pattern": map[string]any{
+ "type": "string",
+ "description": "Search pattern. Supports: words, AND/OR/NOT operators, % wildcard",
+ },
+ "scope": map[string]any{
+ "type": "string",
+ "enum": []string{"both", "summary", "message"},
+ "description": "What to search: 'both' (default), 'summary', or 'message'",
+ },
+ "role": map[string]any{
+ "type": "string",
+ "enum": []string{"user", "assistant"},
+ "description": "Filter by message role (default: all roles)",
+ },
+ "last": map[string]any{
+ "type": "string",
+ "description": "Time shortcut: '6h' (6 hours), '7d' (7 days), '2w' (2 weeks), '1m' (1 month)",
+ },
+ "all_conversations": map[string]any{
+ "type": "boolean",
+ "description": "Search across all conversations (default: searches current conversation only)",
+ },
+ "since": map[string]any{
+ "type": "string",
+ "description": "ISO8601 timestamp, only return content after this time",
+ },
+ "before": map[string]any{
+ "type": "string",
+ "description": "ISO8601 timestamp, only return content before this time",
+ },
+ "limit": map[string]any{
+ "type": "integer",
+ "description": "Maximum number of results (default: 20)",
+ },
+ },
+ "required": []string{"pattern"},
+ }
+}
+
+func (t *GrepTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
+ pattern, ok := args["pattern"].(string)
+ if !ok || pattern == "" {
+ return tools.ErrorResult("Missing required 'pattern' argument. Example: {\"pattern\": \"authentication\"}")
+ }
+
+ input := GrepInput{Pattern: pattern}
+
+ if scope, ok := args["scope"].(string); ok && scope != "" {
+ input.Scope = scope
+ }
+ if role, ok := args["role"].(string); ok && role != "" {
+ input.Role = role
+ }
+ if last, ok := args["last"].(string); ok && last != "" {
+ input.Last = last
+ }
+ if allConv, ok := args["all_conversations"].(bool); ok {
+ input.AllConversations = allConv
+ }
+ if limit, ok := args["limit"].(float64); ok {
+ input.Limit = int(limit)
+ }
+ if sinceStr, ok := args["since"].(string); ok && sinceStr != "" {
+ parsed, err := time.Parse(time.RFC3339, sinceStr)
+ if err != nil {
+ return tools.ErrorResult(fmt.Sprintf(
+ "Invalid 'since' timestamp. Use RFC3339 format like '2024-01-15T10:00:00Z'. Error: %v", err))
+ }
+ input.Since = &parsed
+ }
+ if beforeStr, ok := args["before"].(string); ok && beforeStr != "" {
+ parsed, err := time.Parse(time.RFC3339, beforeStr)
+ if err != nil {
+ return tools.ErrorResult(fmt.Sprintf("Invalid 'before' timestamp format: %v", err))
+ }
+ input.Before = &parsed
+ }
+
+ result, err := t.engine.Grep(ctx, input)
+ if err != nil {
+ return tools.ErrorResult("Grep failed: " + err.Error())
+ }
+
+ // Build response
+ output := map[string]any{
+ "success": result.Success,
+ "summaries": result.Summaries,
+ "messages": result.Messages,
+ }
+
+ // Add hint if provided
+ if result.Hint != "" {
+ output["hint"] = result.Hint
+ }
+
+ data, _ := json.Marshal(output)
+ return tools.NewToolResult(string(data))
+}
diff --git a/pkg/seahorse/tool_grep_test.go b/pkg/seahorse/tool_grep_test.go
new file mode 100644
index 000000000..050d9deeb
--- /dev/null
+++ b/pkg/seahorse/tool_grep_test.go
@@ -0,0 +1,72 @@
+package seahorse
+
+import (
+ "context"
+ "testing"
+)
+
+func TestGrepSearchSummaries(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "test:grep-tool")
+
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "database connection pool configuration",
+ TokenCount: 50,
+ })
+
+ re := &RetrievalEngine{store: s}
+ results, err := re.Grep(ctx, GrepInput{
+ Pattern: "database",
+ })
+ if err != nil {
+ t.Fatalf("Grep: %v", err)
+ }
+ if len(results.Summaries) == 0 {
+ t.Error("expected at least 1 summary result")
+ }
+}
+
+func TestGrepSearchMessages(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "test:grep-msg")
+
+ s.AddMessage(ctx, conv.ConversationID, "user", "find this message about testing", 5)
+ s.AddMessage(ctx, conv.ConversationID, "user", "unrelated content", 3)
+
+ re := &RetrievalEngine{store: s}
+ results, err := re.Grep(ctx, GrepInput{
+ Pattern: "testing",
+ })
+ if err != nil {
+ t.Fatalf("Grep messages: %v", err)
+ }
+ if len(results.Messages) == 0 {
+ t.Error("expected at least 1 message result")
+ }
+}
+
+func TestGrepMissingPattern(t *testing.T) {
+ s := openTestStore(t)
+ re := &RetrievalEngine{store: s}
+ _, err := re.Grep(context.Background(), GrepInput{})
+ if err == nil {
+ t.Error("expected error for missing pattern")
+ }
+}
+
+func TestGrepToolSupportsAllConversations(t *testing.T) {
+ s := openTestStore(t)
+ tool := NewGrepTool(&RetrievalEngine{store: s})
+ params := tool.Parameters()
+ props := params["properties"].(map[string]any)
+
+ // GrepTool should accept all_conversations parameter
+ if _, ok := props["all_conversations"]; !ok {
+ t.Error("Parameters missing 'all_conversations' field")
+ }
+}
diff --git a/pkg/seahorse/types.go b/pkg/seahorse/types.go
new file mode 100644
index 000000000..2bc7f931f
--- /dev/null
+++ b/pkg/seahorse/types.go
@@ -0,0 +1,161 @@
+package seahorse
+
+import (
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/tokenizer"
+)
+
+// SummaryKind distinguishes leaf summaries (from raw messages) vs condensed
+// summaries (from other summaries).
+type SummaryKind string
+
+const (
+ SummaryKindLeaf SummaryKind = "leaf"
+ SummaryKindCondensed SummaryKind = "condensed"
+)
+
+// Message represents a single chat message with role and content.
+type Message struct {
+ ID int64 `json:"id"`
+ ConversationID int64 `json:"conversationId"`
+ Role string `json:"role"`
+ Content string `json:"content"`
+ ReasoningContent string `json:"reasoningContent,omitempty"`
+ TokenCount int `json:"tokenCount"`
+ CreatedAt time.Time `json:"createdAt"`
+ Parts []MessagePart `json:"parts,omitempty"`
+}
+
+// MessagePart holds structured content (tool calls, media, etc.)
+type MessagePart struct {
+ ID int64 `json:"id"`
+ MessageID int64 `json:"messageId"`
+ Type string `json:"type"` // "text", "tool_use", "tool_result", "media"
+ Text string `json:"text"`
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+ ToolCallID string `json:"toolCallId"`
+ MediaURI string `json:"mediaUri"`
+ MimeType string `json:"mimeType"`
+}
+
+// Summary represents a compressed representation of messages or other summaries.
+type Summary struct {
+ SummaryID string `json:"summaryId"`
+ ConversationID int64 `json:"conversationId"`
+ Kind SummaryKind `json:"kind"`
+ Depth int `json:"depth"`
+ Content string `json:"content"`
+ TokenCount int `json:"tokenCount"`
+ EarliestAt *time.Time `json:"earliestAt,omitempty"`
+ LatestAt *time.Time `json:"latestAt,omitempty"`
+ DescendantCount int `json:"descendantCount"`
+ DescendantTokenCount int `json:"descendantTokenCount"`
+ SourceMessageTokenCount int `json:"sourceMessageTokenCount"`
+ Model string `json:"model"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+// SummaryNode is a Summary with graph relationships for tree traversal.
+type SummaryNode struct {
+ Summary
+ Children []string `json:"children"` // Child summary IDs
+ Expanded bool `json:"expanded"` // UI state for expansion
+}
+
+// Conversation represents a session's conversation with metadata.
+type Conversation struct {
+ ConversationID int64 `json:"conversationId"`
+ SessionKey string `json:"sessionKey"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+// SessionStatus contains status information for a session.
+type SessionStatus struct {
+ SessionKey string `json:"sessionKey"`
+ ConversationID int64 `json:"conversationId"`
+ Messages int `json:"messages"`
+ TotalTokens int `json:"totalTokens"`
+ Summaries int `json:"summaries"`
+ OldestAt time.Time `json:"oldestAt"`
+ NewestAt time.Time `json:"newestAt"`
+}
+
+// ContextItem represents one item in the assembled context window.
+type ContextItem struct {
+ ConversationID int64 `json:"conversationId"`
+ Ordinal int `json:"ordinal"`
+ ItemType string `json:"itemType"` // "summary" or "message"
+ SummaryID string `json:"summaryId,omitempty"`
+ MessageID int64 `json:"messageId,omitempty"`
+ TokenCount int `json:"tokenCount"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+// SummarySubtreeNode is a node in a summary DAG subtree.
+type SummarySubtreeNode struct {
+ SummaryID string `json:"summaryId"`
+ DepthFromRoot int `json:"depthFromRoot"`
+}
+
+// SearchInput controls summary search.
+type SearchInput struct {
+ Pattern string `json:"pattern"`
+ Mode string `json:"mode"` // "like" (LIKE search) or "full_text" (FTS5, default)
+ Scope string `json:"scope,omitempty"` // "messages", "summaries", "both"
+ Role string `json:"role,omitempty"` // "user", "assistant", or "" (all)
+ Since *time.Time `json:"since,omitempty"`
+ Before *time.Time `json:"before,omitempty"`
+ Limit int `json:"limit,omitempty"`
+ ConversationID int64 `json:"conversationId,omitempty"`
+ AllConversations bool `json:"allConversations,omitempty"`
+}
+
+// SearchResult is a search match.
+type SearchResult struct {
+ SummaryID string `json:"summaryId,omitempty"`
+ MessageID int64 `json:"messageId,omitempty"`
+ ConversationID int64 `json:"conversationId"`
+ Kind SummaryKind `json:"kind,omitempty"`
+ Depth int `json:"depth,omitempty"`
+ Role string `json:"role,omitempty"`
+ Content string `json:"content,omitempty"` // Full content for summaries
+ Snippet string `json:"snippet"`
+ CreatedAt time.Time `json:"createdAt"`
+ Rank float64 `json:"rank,omitempty"`
+ TotalCount int `json:"totalCount,omitempty"` // Total matching rows (from window function)
+}
+
+// EstimateMessageTokens estimates token count for a full message using the
+// shared tokenizer package for consistency with agent.context_budget.
+func EstimateMessageTokens(msg Message) int {
+ pm := providers.Message{
+ Role: msg.Role,
+ Content: msg.Content,
+ ReasoningContent: msg.ReasoningContent,
+ }
+
+ // Convert MessageParts to ToolCalls / ToolCallID / Media
+ for _, part := range msg.Parts {
+ switch part.Type {
+ case "tool_use":
+ pm.ToolCalls = append(pm.ToolCalls, providers.ToolCall{
+ ID: part.ToolCallID,
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: part.Name,
+ Arguments: part.Arguments,
+ },
+ })
+ case "tool_result":
+ pm.ToolCallID = part.ToolCallID
+ case "media":
+ pm.Media = append(pm.Media, part.MediaURI)
+ }
+ }
+
+ return tokenizer.EstimateMessageTokens(pm)
+}
diff --git a/pkg/seahorse/types_test.go b/pkg/seahorse/types_test.go
new file mode 100644
index 000000000..b7467005f
--- /dev/null
+++ b/pkg/seahorse/types_test.go
@@ -0,0 +1,54 @@
+package seahorse
+
+import (
+ "testing"
+)
+
+func TestSummaryKindValues(t *testing.T) {
+ if SummaryKindLeaf != "leaf" {
+ t.Errorf("expected SummaryKindLeaf = 'leaf', got %q", SummaryKindLeaf)
+ }
+ if SummaryKindCondensed != "condensed" {
+ t.Errorf("expected SummaryKindCondensed = 'condensed', got %q", SummaryKindCondensed)
+ }
+}
+
+func TestConstants(t *testing.T) {
+ // Ordinal gap step
+ if OrdinalStep != 100 {
+ t.Errorf("expected OrdinalStep = 100, got %d", OrdinalStep)
+ }
+
+ // Compaction triggers
+ if ContextThreshold != 0.75 {
+ t.Errorf("expected ContextThreshold = 0.75, got %f", ContextThreshold)
+ }
+ if FreshTailCount != 32 {
+ t.Errorf("expected FreshTailCount = 32, got %d", FreshTailCount)
+ }
+
+ // Fanout
+ if LeafMinFanout != 8 {
+ t.Errorf("expected LeafMinFanout = 8, got %d", LeafMinFanout)
+ }
+ if CondensedMinFanout != 4 {
+ t.Errorf("expected CondensedMinFanout = 4, got %d", CondensedMinFanout)
+ }
+ if CondensedMinFanoutHard != 2 {
+ t.Errorf("expected CondensedMinFanoutHard = 2, got %d", CondensedMinFanoutHard)
+ }
+
+ // Token targets
+ if LeafChunkTokens != 20000 {
+ t.Errorf("expected LeafChunkTokens = 20000, got %d", LeafChunkTokens)
+ }
+ if LeafTargetTokens != 1200 {
+ t.Errorf("expected LeafTargetTokens = 1200, got %d", LeafTargetTokens)
+ }
+ if CondensedTargetTokens != 2000 {
+ t.Errorf("expected CondensedTargetTokens = 2000, got %d", CondensedTargetTokens)
+ }
+ if MaxExpandTokens != 4000 {
+ t.Errorf("expected MaxExpandTokens = 4000, got %d", MaxExpandTokens)
+ }
+}
diff --git a/pkg/session/allocator.go b/pkg/session/allocator.go
new file mode 100644
index 000000000..509550cb2
--- /dev/null
+++ b/pkg/session/allocator.go
@@ -0,0 +1,213 @@
+package session
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/routing"
+)
+
+// Allocation contains the concrete session keys selected for a routed turn.
+// The current implementation intentionally preserves the legacy session-key
+// layout while moving key construction out of the router.
+type Allocation struct {
+ Scope SessionScope
+ SessionKey string
+ SessionAliases []string
+ MainSessionKey string
+ MainAliases []string
+}
+
+// AllocationInput contains the routing result and peer context needed to
+// derive the session keys for a turn.
+type AllocationInput struct {
+ AgentID string
+ Context bus.InboundContext
+ SessionPolicy routing.SessionPolicy
+}
+
+// AllocateRouteSession maps a route decision onto a structured scope and the
+// current opaque session-key format.
+func AllocateRouteSession(input AllocationInput) Allocation {
+ scope := buildSessionScope(input)
+ legacySessionAliases := buildLegacySessionAliases(input)
+ legacyMainSessionKey := strings.ToLower(BuildLegacyMainAlias(input.AgentID))
+ return Allocation{
+ Scope: scope,
+ SessionKey: BuildSessionKey(scope),
+ SessionAliases: legacySessionAliases,
+ MainSessionKey: BuildOpaqueSessionKey(legacyMainSessionKey),
+ MainAliases: []string{legacyMainSessionKey},
+ }
+}
+
+func buildSessionScope(input AllocationInput) SessionScope {
+ inbound := input.Context
+ includeTopicInChatDimension := shouldPreserveTelegramForumIsolation(input)
+ scope := SessionScope{
+ Version: ScopeVersionV1,
+ AgentID: routing.NormalizeAgentID(input.AgentID),
+ Channel: strings.ToLower(strings.TrimSpace(inbound.Channel)),
+ Account: routing.NormalizeAccountID(inbound.Account),
+ }
+ if scope.Channel == "" {
+ scope.Channel = "unknown"
+ }
+
+ dimensions := make([]string, 0, len(input.SessionPolicy.Dimensions))
+ values := make(map[string]string, len(input.SessionPolicy.Dimensions))
+
+ for _, dimension := range input.SessionPolicy.Dimensions {
+ switch dimension {
+ case "space":
+ if spaceID := strings.TrimSpace(inbound.SpaceID); spaceID != "" {
+ spaceType := strings.ToLower(strings.TrimSpace(inbound.SpaceType))
+ if spaceType == "" {
+ spaceType = "space"
+ }
+ dimensions = append(dimensions, "space")
+ values["space"] = fmt.Sprintf("%s:%s", spaceType, strings.ToLower(spaceID))
+ }
+ case "chat":
+ chatID := strings.TrimSpace(inbound.ChatID)
+ if chatID == "" {
+ continue
+ }
+ if includeTopicInChatDimension {
+ if topicID := strings.TrimSpace(inbound.TopicID); topicID != "" {
+ chatID = chatID + "/" + topicID
+ }
+ }
+ chatType := strings.ToLower(strings.TrimSpace(inbound.ChatType))
+ if chatType == "" {
+ chatType = "direct"
+ }
+ dimensions = append(dimensions, "chat")
+ values["chat"] = fmt.Sprintf("%s:%s", chatType, strings.ToLower(chatID))
+ case "topic":
+ if topicID := strings.TrimSpace(inbound.TopicID); topicID != "" {
+ dimensions = append(dimensions, "topic")
+ values["topic"] = "topic:" + strings.ToLower(topicID)
+ }
+ case "sender":
+ senderID := CanonicalSessionIdentityID(
+ inbound.Channel,
+ inbound.SenderID,
+ input.SessionPolicy.IdentityLinks,
+ )
+ if senderID == "" {
+ continue
+ }
+ dimensions = append(dimensions, "sender")
+ values["sender"] = senderID
+ }
+ }
+
+ if len(dimensions) > 0 {
+ scope.Dimensions = dimensions
+ scope.Values = values
+ }
+
+ return scope
+}
+
+func buildLegacySessionAliases(input AllocationInput) []string {
+ aliases := []string{strings.ToLower(BuildLegacyMainAlias(input.AgentID))}
+ inbound := input.Context
+
+ if strings.EqualFold(strings.TrimSpace(inbound.ChatType), "direct") {
+ peerIDs := buildLegacyDirectPeerIDs(input)
+ if len(peerIDs) == 0 {
+ return uniqueAliases(aliases)
+ }
+ for _, peerID := range peerIDs {
+ aliases = append(
+ aliases,
+ BuildLegacyDirectAliases(input.AgentID, inbound.Channel, inbound.Account, peerID)...,
+ )
+ }
+ return uniqueAliases(aliases)
+ }
+
+ peerID := strings.TrimSpace(inbound.ChatID)
+ if peerID == "" {
+ return uniqueAliases(aliases)
+ }
+ if topicID := strings.TrimSpace(inbound.TopicID); topicID != "" {
+ peerID = peerID + "/" + topicID
+ }
+ aliases = append(aliases, BuildLegacyPeerAlias(
+ input.AgentID,
+ inbound.Channel,
+ strings.ToLower(strings.TrimSpace(inbound.ChatType)),
+ peerID,
+ ))
+
+ return uniqueAliases(aliases)
+}
+
+func shouldPreserveTelegramForumIsolation(input AllocationInput) bool {
+ inbound := input.Context
+ if !strings.EqualFold(strings.TrimSpace(inbound.Channel), "telegram") {
+ return false
+ }
+ if strings.TrimSpace(inbound.TopicID) == "" {
+ return false
+ }
+ for _, dimension := range input.SessionPolicy.Dimensions {
+ if strings.EqualFold(strings.TrimSpace(dimension), "topic") {
+ return false
+ }
+ }
+ return true
+}
+
+func buildLegacyDirectPeerIDs(input AllocationInput) []string {
+ inbound := input.Context
+ peerIDs := make([]string, 0, 3)
+
+ rawSenderID := strings.TrimSpace(inbound.SenderID)
+ if rawSenderID != "" {
+ peerIDs = append(peerIDs, strings.ToLower(rawSenderID))
+ }
+
+ canonicalSenderID := CanonicalSessionIdentityID(
+ inbound.Channel,
+ inbound.SenderID,
+ input.SessionPolicy.IdentityLinks,
+ )
+ if canonicalSenderID != "" {
+ peerIDs = append(peerIDs, canonicalSenderID)
+ }
+
+ chatID := strings.TrimSpace(inbound.ChatID)
+ if chatID != "" {
+ peerIDs = append(peerIDs, strings.ToLower(chatID))
+ }
+
+ return uniqueAliases(peerIDs)
+}
+
+func uniqueAliases(aliases []string) []string {
+ if len(aliases) == 0 {
+ return nil
+ }
+ normalized := make([]string, 0, len(aliases))
+ seen := make(map[string]struct{}, len(aliases))
+ for _, alias := range aliases {
+ alias = strings.TrimSpace(strings.ToLower(alias))
+ if alias == "" {
+ continue
+ }
+ if _, ok := seen[alias]; ok {
+ continue
+ }
+ seen[alias] = struct{}{}
+ normalized = append(normalized, alias)
+ }
+ if len(normalized) == 0 {
+ return nil
+ }
+ return normalized
+}
diff --git a/pkg/session/allocator_test.go b/pkg/session/allocator_test.go
new file mode 100644
index 000000000..9750ffc39
--- /dev/null
+++ b/pkg/session/allocator_test.go
@@ -0,0 +1,160 @@
+package session
+
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/routing"
+)
+
+func TestAllocateRouteSession_PerPeerDM(t *testing.T) {
+ allocation := AllocateRouteSession(AllocationInput{
+ AgentID: "main",
+ Context: bus.InboundContext{
+ Channel: "telegram",
+ Account: "default",
+ ChatID: "dm-123",
+ ChatType: "direct",
+ SenderID: "User123",
+ },
+ SessionPolicy: routing.SessionPolicy{
+ Dimensions: []string{"sender"},
+ },
+ })
+
+ if allocation.SessionKey == "" || !IsOpaqueSessionKey(allocation.SessionKey) {
+ t.Fatalf("SessionKey = %q, want opaque session key", allocation.SessionKey)
+ }
+ if !containsAlias(allocation.SessionAliases, "agent:main:direct:user123") {
+ t.Fatalf("SessionAliases = %v, want to contain agent:main:direct:user123", allocation.SessionAliases)
+ }
+ if allocation.MainSessionKey == "" || !IsOpaqueSessionKey(allocation.MainSessionKey) {
+ t.Fatalf("MainSessionKey = %q, want opaque session key", allocation.MainSessionKey)
+ }
+ if len(allocation.MainAliases) != 1 || allocation.MainAliases[0] != "agent:main:main" {
+ t.Fatalf("MainAliases = %v, want [agent:main:main]", allocation.MainAliases)
+ }
+ if allocation.Scope.Version != ScopeVersionV1 {
+ t.Fatalf("Scope.Version = %d, want %d", allocation.Scope.Version, ScopeVersionV1)
+ }
+ if len(allocation.Scope.Dimensions) != 1 || allocation.Scope.Dimensions[0] != "sender" {
+ t.Fatalf("Scope.Dimensions = %v, want [sender]", allocation.Scope.Dimensions)
+ }
+ if allocation.Scope.Values["sender"] != "user123" {
+ t.Fatalf("Scope.Values[sender] = %q, want user123", allocation.Scope.Values["sender"])
+ }
+}
+
+func TestAllocateRouteSession_GroupPeer(t *testing.T) {
+ allocation := AllocateRouteSession(AllocationInput{
+ AgentID: "main",
+ Context: bus.InboundContext{
+ Channel: "slack",
+ Account: "workspace-a",
+ ChatID: "C001",
+ ChatType: "channel",
+ SenderID: "U001",
+ },
+ SessionPolicy: routing.SessionPolicy{
+ Dimensions: []string{"chat"},
+ },
+ })
+
+ if allocation.SessionKey == "" || !IsOpaqueSessionKey(allocation.SessionKey) {
+ t.Fatalf("SessionKey = %q, want opaque session key", allocation.SessionKey)
+ }
+ if !containsAlias(allocation.SessionAliases, "agent:main:slack:channel:c001") {
+ t.Fatalf("SessionAliases = %v, want to contain agent:main:slack:channel:c001", allocation.SessionAliases)
+ }
+ if allocation.MainSessionKey == "" || !IsOpaqueSessionKey(allocation.MainSessionKey) {
+ t.Fatalf("MainSessionKey = %q, want opaque session key", allocation.MainSessionKey)
+ }
+ if len(allocation.MainAliases) != 1 || allocation.MainAliases[0] != "agent:main:main" {
+ t.Fatalf("MainAliases = %v, want [agent:main:main]", allocation.MainAliases)
+ }
+ if len(allocation.Scope.Dimensions) != 1 || allocation.Scope.Dimensions[0] != "chat" {
+ t.Fatalf("Scope.Dimensions = %v, want [chat]", allocation.Scope.Dimensions)
+ }
+ if allocation.Scope.Values["chat"] != "channel:c001" {
+ t.Fatalf("Scope.Values[chat] = %q, want channel:c001", allocation.Scope.Values["chat"])
+ }
+}
+
+func TestAllocateRouteSession_TelegramForumTopicsRemainIsolatedByDefault(t *testing.T) {
+ first := AllocateRouteSession(AllocationInput{
+ AgentID: "main",
+ Context: bus.InboundContext{
+ Channel: "telegram",
+ ChatID: "-1001234567890",
+ ChatType: "group",
+ TopicID: "42",
+ SenderID: "7",
+ },
+ SessionPolicy: routing.SessionPolicy{
+ Dimensions: []string{"chat"},
+ },
+ })
+ second := AllocateRouteSession(AllocationInput{
+ AgentID: "main",
+ Context: bus.InboundContext{
+ Channel: "telegram",
+ ChatID: "-1001234567890",
+ ChatType: "group",
+ TopicID: "99",
+ SenderID: "7",
+ },
+ SessionPolicy: routing.SessionPolicy{
+ Dimensions: []string{"chat"},
+ },
+ })
+
+ if first.SessionKey == second.SessionKey {
+ t.Fatalf("forum topics should not share default session key: %q", first.SessionKey)
+ }
+ if got := first.Scope.Values["chat"]; got != "group:-1001234567890/42" {
+ t.Fatalf("first.Scope.Values[chat] = %q, want %q", got, "group:-1001234567890/42")
+ }
+ if got := second.Scope.Values["chat"]; got != "group:-1001234567890/99" {
+ t.Fatalf("second.Scope.Values[chat] = %q, want %q", got, "group:-1001234567890/99")
+ }
+}
+
+func TestAllocateRouteSession_PicoDirectAliasesIncludeLegacyChatKey(t *testing.T) {
+ allocation := AllocateRouteSession(AllocationInput{
+ AgentID: "main",
+ Context: bus.InboundContext{
+ Channel: "pico",
+ Account: "default",
+ ChatID: "pico:session-123",
+ ChatType: "direct",
+ SenderID: "pico-user",
+ },
+ SessionPolicy: routing.SessionPolicy{
+ Dimensions: []string{"sender"},
+ },
+ })
+
+ if !containsAlias(allocation.SessionAliases, "agent:main:pico:direct:pico:session-123") {
+ t.Fatalf("SessionAliases = %v, want pico legacy alias", allocation.SessionAliases)
+ }
+}
+
+func TestBuildOpaqueSessionKey_IsStable(t *testing.T) {
+ first := BuildOpaqueSessionKey("agent:main:direct:user123")
+ second := BuildOpaqueSessionKey("agent:main:direct:user123")
+ if first != second {
+ t.Fatalf("BuildOpaqueSessionKey() mismatch: %q != %q", first, second)
+ }
+ if !IsOpaqueSessionKey(first) {
+ t.Fatalf("expected opaque session key, got %q", first)
+ }
+}
+
+func containsAlias(aliases []string, want string) bool {
+ for _, alias := range aliases {
+ if alias == want {
+ return true
+ }
+ }
+ return false
+}
diff --git a/pkg/session/jsonl_backend.go b/pkg/session/jsonl_backend.go
index 7f470de15..68ef2d753 100644
--- a/pkg/session/jsonl_backend.go
+++ b/pkg/session/jsonl_backend.go
@@ -2,7 +2,9 @@ package session
import (
"context"
+ "encoding/json"
"log"
+ "strings"
"github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/providers"
@@ -15,24 +17,123 @@ type JSONLBackend struct {
store memory.Store
}
+type metaAwareStore interface {
+ GetSessionMeta(ctx context.Context, sessionKey string) (memory.SessionMeta, error)
+ UpsertSessionMeta(ctx context.Context, sessionKey string, scope json.RawMessage, aliases []string) error
+ ResolveSessionKey(ctx context.Context, sessionKey string) (string, bool, error)
+}
+
+type aliasPromotingStore interface {
+ PromoteAliasHistory(ctx context.Context, sessionKey string, scope json.RawMessage, aliases []string) (bool, error)
+}
+
+// MetadataAwareSessionStore exposes structured session metadata operations.
+type MetadataAwareSessionStore interface {
+ EnsureSessionMetadata(sessionKey string, scope *SessionScope, aliases []string)
+ ResolveSessionKey(sessionKey string) string
+ GetSessionScope(sessionKey string) *SessionScope
+}
+
// NewJSONLBackend wraps a memory.Store for use as a SessionStore.
func NewJSONLBackend(store memory.Store) *JSONLBackend {
return &JSONLBackend{store: store}
}
+func (b *JSONLBackend) resolveSessionKey(sessionKey string) string {
+ metaStore, ok := b.store.(metaAwareStore)
+ if !ok {
+ return sessionKey
+ }
+ resolved, found, err := metaStore.ResolveSessionKey(context.Background(), sessionKey)
+ if err != nil {
+ log.Printf("session: resolve session key: %v", err)
+ return sessionKey
+ }
+ if found && resolved != "" {
+ return resolved
+ }
+ return sessionKey
+}
+
+// ResolveSessionKey maps aliases onto their canonical session key when the
+// underlying store supports structured metadata. Unknown aliases fall back to
+// the original input so existing callers remain compatible.
+func (b *JSONLBackend) ResolveSessionKey(sessionKey string) string {
+ return b.resolveSessionKey(sessionKey)
+}
+
+// EnsureSessionMetadata persists scope and alias metadata for a session.
+func (b *JSONLBackend) EnsureSessionMetadata(sessionKey string, scope *SessionScope, aliases []string) {
+ metaStore, ok := b.store.(metaAwareStore)
+ if !ok {
+ return
+ }
+ sessionKey = strings.TrimSpace(sessionKey)
+ if sessionKey == "" {
+ return
+ }
+
+ var rawScope json.RawMessage
+ if scope != nil {
+ data, err := json.Marshal(scope)
+ if err != nil {
+ log.Printf("session: encode session scope: %v", err)
+ return
+ }
+ rawScope = data
+ }
+ ctx := context.Background()
+ if err := metaStore.UpsertSessionMeta(ctx, sessionKey, rawScope, aliases); err != nil {
+ log.Printf("session: upsert session metadata: %v", err)
+ return
+ }
+
+ if promotingStore, ok := b.store.(aliasPromotingStore); ok {
+ if _, err := promotingStore.PromoteAliasHistory(ctx, sessionKey, rawScope, aliases); err != nil {
+ log.Printf("session: promote alias history: %v", err)
+ }
+ }
+}
+
+// GetSessionScope reads structured scope metadata for a session key or alias.
+func (b *JSONLBackend) GetSessionScope(sessionKey string) *SessionScope {
+ metaStore, ok := b.store.(metaAwareStore)
+ if !ok {
+ return nil
+ }
+ sessionKey = b.resolveSessionKey(sessionKey)
+ meta, err := metaStore.GetSessionMeta(context.Background(), sessionKey)
+ if err != nil {
+ log.Printf("session: get session metadata: %v", err)
+ return nil
+ }
+ if len(meta.Scope) == 0 {
+ return nil
+ }
+ var scope SessionScope
+ if err := json.Unmarshal(meta.Scope, &scope); err != nil {
+ log.Printf("session: decode session scope: %v", err)
+ return nil
+ }
+ return CloneScope(&scope)
+}
+
func (b *JSONLBackend) AddMessage(sessionKey, role, content string) {
+ sessionKey = b.resolveSessionKey(sessionKey)
if err := b.store.AddMessage(context.Background(), sessionKey, role, content); err != nil {
log.Printf("session: add message: %v", err)
}
}
func (b *JSONLBackend) AddFullMessage(sessionKey string, msg providers.Message) {
+ sessionKey = b.resolveSessionKey(sessionKey)
if err := b.store.AddFullMessage(context.Background(), sessionKey, msg); err != nil {
log.Printf("session: add full message: %v", err)
}
}
func (b *JSONLBackend) GetHistory(key string) []providers.Message {
+ key = b.resolveSessionKey(key)
msgs, err := b.store.GetHistory(context.Background(), key)
if err != nil {
log.Printf("session: get history: %v", err)
@@ -42,6 +143,7 @@ func (b *JSONLBackend) GetHistory(key string) []providers.Message {
}
func (b *JSONLBackend) GetSummary(key string) string {
+ key = b.resolveSessionKey(key)
summary, err := b.store.GetSummary(context.Background(), key)
if err != nil {
log.Printf("session: get summary: %v", err)
@@ -51,18 +153,21 @@ func (b *JSONLBackend) GetSummary(key string) string {
}
func (b *JSONLBackend) SetSummary(key, summary string) {
+ key = b.resolveSessionKey(key)
if err := b.store.SetSummary(context.Background(), key, summary); err != nil {
log.Printf("session: set summary: %v", err)
}
}
func (b *JSONLBackend) SetHistory(key string, history []providers.Message) {
+ key = b.resolveSessionKey(key)
if err := b.store.SetHistory(context.Background(), key, history); err != nil {
log.Printf("session: set history: %v", err)
}
}
func (b *JSONLBackend) TruncateHistory(key string, keepLast int) {
+ key = b.resolveSessionKey(key)
if err := b.store.TruncateHistory(context.Background(), key, keepLast); err != nil {
log.Printf("session: truncate history: %v", err)
}
@@ -72,6 +177,7 @@ func (b *JSONLBackend) TruncateHistory(key string, keepLast int) {
// immediately, the data is already durable. Save runs compaction to reclaim
// space from logically truncated messages (no-op when there are none).
func (b *JSONLBackend) Save(key string) error {
+ key = b.resolveSessionKey(key)
return b.store.Compact(context.Background(), key)
}
@@ -79,3 +185,8 @@ func (b *JSONLBackend) Save(key string) error {
func (b *JSONLBackend) Close() error {
return b.store.Close()
}
+
+// ListSessions returns all known session keys.
+func (b *JSONLBackend) ListSessions() []string {
+ return b.store.ListSessions()
+}
diff --git a/pkg/session/jsonl_backend_test.go b/pkg/session/jsonl_backend_test.go
index 40fa019cb..0b79ad84d 100644
--- a/pkg/session/jsonl_backend_test.go
+++ b/pkg/session/jsonl_backend_test.go
@@ -4,8 +4,10 @@ import (
"fmt"
"testing"
+ "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/session"
)
@@ -177,3 +179,126 @@ func TestJSONLBackend_SummarizeFlow(t *testing.T) {
t.Errorf("first message = %q, want %q", history[0].Content, "msg 16")
}
}
+
+func TestJSONLBackend_ResolveAliasAndPersistMetadata(t *testing.T) {
+ b := newBackend(t)
+
+ scope := &session.SessionScope{
+ Version: session.ScopeVersionV1,
+ AgentID: "main",
+ Channel: "telegram",
+ Account: "default",
+ Dimensions: []string{"chat"},
+ Values: map[string]string{
+ "chat": "group:c1",
+ },
+ }
+ b.EnsureSessionMetadata("canonical", scope, []string{"legacy"})
+
+ if got := b.ResolveSessionKey("legacy"); got != "canonical" {
+ t.Fatalf("ResolveSessionKey() = %q, want %q", got, "canonical")
+ }
+
+ b.AddMessage("legacy", "user", "hello through alias")
+ history := b.GetHistory("canonical")
+ if len(history) != 1 {
+ t.Fatalf("len(history) = %d, want 1", len(history))
+ }
+ if history[0].Content != "hello through alias" {
+ t.Fatalf("history[0].Content = %q, want %q", history[0].Content, "hello through alias")
+ }
+
+ resolvedScope := b.GetSessionScope("legacy")
+ if resolvedScope == nil {
+ t.Fatal("GetSessionScope() returned nil")
+ }
+ if resolvedScope.AgentID != scope.AgentID || resolvedScope.Values["chat"] != scope.Values["chat"] {
+ t.Fatalf("GetSessionScope() = %+v, want %+v", resolvedScope, scope)
+ }
+}
+
+func TestJSONLBackend_EnsureSessionMetadata_PromotesLegacyAliasHistory(t *testing.T) {
+ b := newBackend(t)
+
+ legacyKey := "agent:main:direct:legacy-user"
+ b.AddMessage(legacyKey, "user", "legacy history")
+ b.SetSummary(legacyKey, "legacy summary")
+
+ canonicalKey := session.BuildOpaqueSessionKey(legacyKey)
+ b.EnsureSessionMetadata(canonicalKey, &session.SessionScope{
+ Version: session.ScopeVersionV1,
+ AgentID: "main",
+ }, []string{legacyKey})
+
+ if got := b.ResolveSessionKey(legacyKey); got != canonicalKey {
+ t.Fatalf("ResolveSessionKey() = %q, want %q", got, canonicalKey)
+ }
+ history := b.GetHistory(canonicalKey)
+ if len(history) != 1 || history[0].Content != "legacy history" {
+ t.Fatalf("promoted history = %+v", history)
+ }
+ if summary := b.GetSummary(canonicalKey); summary != "legacy summary" {
+ t.Fatalf("promoted summary = %q, want %q", summary, "legacy summary")
+ }
+}
+
+func TestJSONLBackend_EnsureSessionMetadata_PromotesLegacyPicoDirectAliasHistory(t *testing.T) {
+ b := newBackend(t)
+
+ legacyKey := "agent:main:pico:direct:pico:session-123"
+ b.AddMessage(legacyKey, "user", "legacy pico history")
+
+ scope := &session.SessionScope{
+ Version: session.ScopeVersionV1,
+ AgentID: "main",
+ Channel: "pico",
+ Account: "default",
+ Dimensions: []string{"sender"},
+ Values: map[string]string{
+ "sender": "pico-user",
+ },
+ }
+ allocation := session.AllocateRouteSession(session.AllocationInput{
+ AgentID: "main",
+ Context: bus.InboundContext{
+ Channel: "pico",
+ Account: "default",
+ ChatID: "pico:session-123",
+ ChatType: "direct",
+ SenderID: "pico-user",
+ },
+ SessionPolicy: routing.SessionPolicy{
+ Dimensions: []string{"sender"},
+ },
+ })
+
+ b.EnsureSessionMetadata(allocation.SessionKey, scope, allocation.SessionAliases)
+
+ if got := b.ResolveSessionKey(legacyKey); got != allocation.SessionKey {
+ t.Fatalf("ResolveSessionKey() = %q, want %q", got, allocation.SessionKey)
+ }
+ history := b.GetHistory(allocation.SessionKey)
+ if len(history) != 1 || history[0].Content != "legacy pico history" {
+ t.Fatalf("promoted history = %+v", history)
+ }
+}
+
+func TestJSONLBackend_EnsureSessionMetadata_DoesNotOverwriteNonEmptyCanonicalHistory(t *testing.T) {
+ b := newBackend(t)
+
+ canonicalKey := session.BuildOpaqueSessionKey("agent:main:direct:current-user")
+ legacyKey := "agent:main:direct:legacy-user"
+
+ b.AddMessage(canonicalKey, "user", "current canonical history")
+ b.AddMessage(legacyKey, "user", "legacy history")
+
+ b.EnsureSessionMetadata(canonicalKey, &session.SessionScope{
+ Version: session.ScopeVersionV1,
+ AgentID: "main",
+ }, []string{legacyKey})
+
+ history := b.GetHistory(canonicalKey)
+ if len(history) != 1 || history[0].Content != "current canonical history" {
+ t.Fatalf("canonical history overwritten: %+v", history)
+ }
+}
diff --git a/pkg/session/key.go b/pkg/session/key.go
new file mode 100644
index 000000000..fb0836bc1
--- /dev/null
+++ b/pkg/session/key.go
@@ -0,0 +1,205 @@
+package session
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/routing"
+)
+
+const (
+ sessionKeyV1Prefix = "sk_v1_"
+ legacyAgentSessionKeyPrefix = "agent:"
+)
+
+type ParsedLegacySessionKey struct {
+ AgentID string
+ Rest string
+}
+
+// BuildOpaqueSessionKey returns a stable opaque session key derived from a
+// canonical alias string. The alias remains available through metadata for
+// compatibility and migration purposes.
+func BuildOpaqueSessionKey(alias string) string {
+ normalized := strings.TrimSpace(strings.ToLower(alias))
+ if normalized == "" {
+ return ""
+ }
+ sum := sha256.Sum256([]byte(normalized))
+ return sessionKeyV1Prefix + hex.EncodeToString(sum[:])
+}
+
+// IsOpaqueSessionKey returns true when the key matches the current opaque
+// session-key format.
+func IsOpaqueSessionKey(key string) bool {
+ return strings.HasPrefix(strings.ToLower(strings.TrimSpace(key)), sessionKeyV1Prefix)
+}
+
+func IsLegacyAgentSessionKey(key string) bool {
+ return strings.HasPrefix(strings.ToLower(strings.TrimSpace(key)), legacyAgentSessionKeyPrefix)
+}
+
+func IsExplicitSessionKey(key string) bool {
+ return IsOpaqueSessionKey(key) || IsLegacyAgentSessionKey(key)
+}
+
+func ParseLegacyAgentSessionKey(sessionKey string) *ParsedLegacySessionKey {
+ raw := strings.TrimSpace(sessionKey)
+ if raw == "" {
+ return nil
+ }
+ parts := strings.SplitN(raw, ":", 3)
+ if len(parts) < 3 || parts[0] != "agent" {
+ return nil
+ }
+ agentID := strings.TrimSpace(parts[1])
+ rest := parts[2]
+ if agentID == "" || rest == "" {
+ return nil
+ }
+ return &ParsedLegacySessionKey{AgentID: agentID, Rest: rest}
+}
+
+// ResolveAgentID returns the routed agent ID associated with a session. It
+// prefers structured session scope metadata when available and falls back to
+// legacy agent-scoped session keys for compatibility.
+func ResolveAgentID(store any, sessionKey string) string {
+ if scopeReader, ok := store.(interface {
+ GetSessionScope(sessionKey string) *SessionScope
+ }); ok {
+ scope := scopeReader.GetSessionScope(sessionKey)
+ if scope != nil && strings.TrimSpace(scope.AgentID) != "" {
+ return routing.NormalizeAgentID(scope.AgentID)
+ }
+ }
+
+ if parsed := ParseLegacyAgentSessionKey(sessionKey); parsed != nil {
+ return routing.NormalizeAgentID(parsed.AgentID)
+ }
+
+ return ""
+}
+
+func BuildLegacyMainAlias(agentID string) string {
+ return fmt.Sprintf("agent:%s:main", routing.NormalizeAgentID(agentID))
+}
+
+// BuildMainSessionKey returns the canonical opaque main-session key for an
+// agent. The corresponding legacy alias remains available via
+// BuildLegacyMainAlias for compatibility and migration logic.
+func BuildMainSessionKey(agentID string) string {
+ return BuildOpaqueSessionKey(BuildLegacyMainAlias(agentID))
+}
+
+func BuildLegacyDirectAliases(agentID, channel, account, peerID string) []string {
+ agentID = routing.NormalizeAgentID(agentID)
+ channel = normalizeLegacyChannel(channel)
+ account = routing.NormalizeAccountID(account)
+ peerID = strings.ToLower(strings.TrimSpace(peerID))
+ if peerID == "" {
+ return nil
+ }
+ return []string{
+ fmt.Sprintf("agent:%s:direct:%s", agentID, peerID),
+ fmt.Sprintf("agent:%s:%s:direct:%s", agentID, channel, peerID),
+ fmt.Sprintf("agent:%s:%s:%s:direct:%s", agentID, channel, account, peerID),
+ }
+}
+
+func BuildLegacyPeerAlias(agentID, channel, peerKind, peerID string) string {
+ agentID = routing.NormalizeAgentID(agentID)
+ channel = normalizeLegacyChannel(channel)
+ peerKind = strings.ToLower(strings.TrimSpace(peerKind))
+ if peerKind == "" {
+ peerKind = "unknown"
+ }
+ peerID = strings.ToLower(strings.TrimSpace(peerID))
+ if peerID == "" {
+ peerID = "unknown"
+ }
+ return fmt.Sprintf("agent:%s:%s:%s:%s", agentID, channel, peerKind, peerID)
+}
+
+// CanonicalSessionIdentityID collapses an identity using identity_links when
+// possible, then returns a normalized lowercase identifier.
+func CanonicalSessionIdentityID(channel, rawID string, identityLinks map[string][]string) string {
+ normalizedID := strings.TrimSpace(rawID)
+ if normalizedID == "" {
+ return ""
+ }
+ if linked := resolveLinkedPeerID(identityLinks, channel, normalizedID); linked != "" {
+ normalizedID = linked
+ }
+ return strings.ToLower(normalizedID)
+}
+
+func normalizeLegacyChannel(channel string) string {
+ channel = strings.ToLower(strings.TrimSpace(channel))
+ if channel == "" {
+ return "unknown"
+ }
+ return channel
+}
+
+func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID string) string {
+ if len(identityLinks) == 0 {
+ return ""
+ }
+ peerID = strings.TrimSpace(peerID)
+ if peerID == "" {
+ return ""
+ }
+
+ candidates := make(map[string]bool)
+ rawCandidate := strings.ToLower(peerID)
+ if rawCandidate != "" {
+ candidates[rawCandidate] = true
+ }
+ channel = strings.ToLower(strings.TrimSpace(channel))
+ if channel != "" {
+ candidates[fmt.Sprintf("%s:%s", channel, rawCandidate)] = true
+ }
+ if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 {
+ candidates[rawCandidate[idx+1:]] = true
+ }
+
+ for canonical, ids := range identityLinks {
+ canonicalName := strings.TrimSpace(canonical)
+ if canonicalName == "" {
+ continue
+ }
+ for _, id := range ids {
+ normalized := strings.ToLower(strings.TrimSpace(id))
+ if normalized != "" && candidates[normalized] {
+ return canonicalName
+ }
+ }
+ }
+ return ""
+}
+
+// CanonicalScopeSignature returns a stable serialized representation of scope.
+func CanonicalScopeSignature(scope SessionScope) string {
+ parts := []string{
+ fmt.Sprintf("v=%d", scope.Version),
+ fmt.Sprintf("agent=%s", strings.TrimSpace(strings.ToLower(scope.AgentID))),
+ fmt.Sprintf("channel=%s", strings.TrimSpace(strings.ToLower(scope.Channel))),
+ fmt.Sprintf("account=%s", strings.TrimSpace(strings.ToLower(scope.Account))),
+ }
+ for _, dimension := range scope.Dimensions {
+ dimension = strings.TrimSpace(strings.ToLower(dimension))
+ if dimension == "" {
+ continue
+ }
+ value := strings.TrimSpace(strings.ToLower(scope.Values[dimension]))
+ parts = append(parts, fmt.Sprintf("%s=%s", dimension, value))
+ }
+ return strings.Join(parts, "|")
+}
+
+// BuildSessionKey returns the current opaque key for a structured session scope.
+func BuildSessionKey(scope SessionScope) string {
+ return BuildOpaqueSessionKey(CanonicalScopeSignature(scope))
+}
diff --git a/pkg/session/key_test.go b/pkg/session/key_test.go
new file mode 100644
index 000000000..6cdf397e1
--- /dev/null
+++ b/pkg/session/key_test.go
@@ -0,0 +1,100 @@
+package session
+
+import "testing"
+
+type testScopeReader struct {
+ scope *SessionScope
+}
+
+func (r testScopeReader) GetSessionScope(sessionKey string) *SessionScope {
+ return CloneScope(r.scope)
+}
+
+func TestIsExplicitSessionKey(t *testing.T) {
+ tests := []struct {
+ key string
+ want bool
+ }{
+ {"sk_v1_abc", true},
+ {"agent:main:direct:user123", true},
+ {"custom-key", false},
+ {"", false},
+ }
+
+ for _, tt := range tests {
+ if got := IsExplicitSessionKey(tt.key); got != tt.want {
+ t.Fatalf("IsExplicitSessionKey(%q) = %v, want %v", tt.key, got, tt.want)
+ }
+ }
+}
+
+func TestParseLegacyAgentSessionKey(t *testing.T) {
+ parsed := ParseLegacyAgentSessionKey("agent:sales:telegram:direct:user123")
+ if parsed == nil {
+ t.Fatal("expected parsed legacy key, got nil")
+ }
+ if parsed.AgentID != "sales" {
+ t.Fatalf("AgentID = %q, want sales", parsed.AgentID)
+ }
+ if parsed.Rest != "telegram:direct:user123" {
+ t.Fatalf("Rest = %q, want telegram:direct:user123", parsed.Rest)
+ }
+
+ if got := ParseLegacyAgentSessionKey("sk_v1_abc"); got != nil {
+ t.Fatalf("expected nil for opaque key, got %+v", got)
+ }
+}
+
+func TestBuildLegacyDirectAliases(t *testing.T) {
+ aliases := BuildLegacyDirectAliases("Main", "Telegram", "BotA", "User123")
+ want := []string{
+ "agent:main:direct:user123",
+ "agent:main:telegram:direct:user123",
+ "agent:main:telegram:bota:direct:user123",
+ }
+ if len(aliases) != len(want) {
+ t.Fatalf("len(aliases) = %d, want %d", len(aliases), len(want))
+ }
+ for i := range want {
+ if aliases[i] != want[i] {
+ t.Fatalf("aliases[%d] = %q, want %q", i, aliases[i], want[i])
+ }
+ }
+}
+
+func TestBuildLegacyPeerAlias(t *testing.T) {
+ got := BuildLegacyPeerAlias("Main", "Slack", "channel", "C001")
+ if got != "agent:main:slack:channel:c001" {
+ t.Fatalf("BuildLegacyPeerAlias() = %q", got)
+ }
+}
+
+func TestBuildMainSessionKey(t *testing.T) {
+ got := BuildMainSessionKey("Main")
+ if !IsOpaqueSessionKey(got) {
+ t.Fatalf("BuildMainSessionKey() = %q, want opaque key", got)
+ }
+ if got != BuildOpaqueSessionKey("agent:main:main") {
+ t.Fatalf("BuildMainSessionKey() = %q, want stable main-key hash", got)
+ }
+}
+
+func TestResolveAgentID_PrefersSessionScope(t *testing.T) {
+ store := testScopeReader{
+ scope: &SessionScope{
+ Version: ScopeVersionV1,
+ AgentID: "Support",
+ Channel: "slack",
+ },
+ }
+
+ if got := ResolveAgentID(store, "sk_v1_anything"); got != "support" {
+ t.Fatalf("ResolveAgentID() = %q, want support", got)
+ }
+}
+
+func TestResolveAgentID_FallsBackToLegacyKey(t *testing.T) {
+ if got := ResolveAgentID(nil, "agent:Sales:telegram:direct:user123"); got != "sales" {
+ t.Fatalf("ResolveAgentID() = %q, want sales", got)
+ }
+}
diff --git a/pkg/session/manager.go b/pkg/session/manager.go
index ef720b7c5..1d6fa3106 100644
--- a/pkg/session/manager.go
+++ b/pkg/session/manager.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/providers/messageutil"
)
type Session struct {
@@ -69,6 +70,10 @@ func (sm *SessionManager) AddMessage(sessionKey, role, content string) {
// AddFullMessage adds a complete message with tool calls and tool call ID to the session.
// This is used to save the full conversation flow including tool calls and tool results.
func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Message) {
+ if messageutil.IsTransientAssistantThoughtMessage(msg) {
+ return
+ }
+
sm.mu.Lock()
defer sm.mu.Unlock()
@@ -145,6 +150,16 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) {
session.Updated = time.Now()
}
+func (sm *SessionManager) ListSessions() []string {
+ sm.mu.RLock()
+ defer sm.mu.RUnlock()
+ keys := make([]string, 0, len(sm.sessions))
+ for k := range sm.sessions {
+ keys = append(keys, k)
+ }
+ return keys
+}
+
// sanitizeFilename converts a session key into a cross-platform safe filename.
// Replaces ':' with '_' (session key separator) and '/' and '\' with '_' so
// composite IDs (e.g. Telegram forum "chatID/threadID") do not create
@@ -186,8 +201,7 @@ func (sm *SessionManager) Save(key string) error {
Updated: stored.Updated,
}
if len(stored.Messages) > 0 {
- snapshot.Messages = make([]providers.Message, len(stored.Messages))
- copy(snapshot.Messages, stored.Messages)
+ snapshot.Messages = messageutil.FilterInvalidHistoryMessages(stored.Messages)
} else {
snapshot.Messages = []providers.Message{}
}
@@ -260,6 +274,7 @@ func (sm *SessionManager) loadSessions() error {
if err := json.Unmarshal(data, &session); err != nil {
continue
}
+ session.Messages = messageutil.FilterInvalidHistoryMessages(session.Messages)
sm.sessions[session.Key] = &session
}
@@ -280,6 +295,7 @@ func (sm *SessionManager) SetHistory(key string, history []providers.Message) {
session, ok := sm.sessions[key]
if ok {
+ history = messageutil.FilterInvalidHistoryMessages(history)
// Create a deep copy to strictly isolate internal state
// from the caller's slice.
msgs := make([]providers.Message, len(history))
diff --git a/pkg/session/scope.go b/pkg/session/scope.go
new file mode 100644
index 000000000..efb026ea3
--- /dev/null
+++ b/pkg/session/scope.go
@@ -0,0 +1,32 @@
+package session
+
+// ScopeVersionV1 is the first structured session-scope schema version.
+const ScopeVersionV1 = 1
+
+// SessionScope describes the semantic session partition selected for a turn.
+type SessionScope struct {
+ Version int `json:"version"`
+ AgentID string `json:"agent_id"`
+ Channel string `json:"channel"`
+ Account string `json:"account"`
+ Dimensions []string `json:"dimensions"`
+ Values map[string]string `json:"values"`
+}
+
+// CloneScope returns a deep copy of scope.
+func CloneScope(scope *SessionScope) *SessionScope {
+ if scope == nil {
+ return nil
+ }
+ cloned := *scope
+ if len(scope.Dimensions) > 0 {
+ cloned.Dimensions = append([]string(nil), scope.Dimensions...)
+ }
+ if len(scope.Values) > 0 {
+ cloned.Values = make(map[string]string, len(scope.Values))
+ for key, value := range scope.Values {
+ cloned.Values[key] = value
+ }
+ }
+ return &cloned
+}
diff --git a/pkg/session/session_store.go b/pkg/session/session_store.go
index 1d1a2f967..2ba2a974d 100644
--- a/pkg/session/session_store.go
+++ b/pkg/session/session_store.go
@@ -27,6 +27,8 @@ type SessionStore interface {
TruncateHistory(key string, keepLast int)
// Save persists any pending state to durable storage.
Save(key string) error
+ // ListSessions returns all known session keys.
+ ListSessions() []string
// Close releases resources held by the store.
Close() error
}
diff --git a/pkg/skills/clawhub_registry.go b/pkg/skills/clawhub_registry.go
index bd4bed8fb..677a57f18 100644
--- a/pkg/skills/clawhub_registry.go
+++ b/pkg/skills/clawhub_registry.go
@@ -5,11 +5,13 @@ import (
"encoding/json"
"fmt"
"io"
+ "log/slog"
"net/http"
"net/url"
"os"
"time"
+ "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/utils"
)
@@ -19,6 +21,35 @@ const (
defaultMaxResponseSize = 2 * 1024 * 1024 // 2 MB
)
+func init() {
+ RegisterRegistryProviderBuilder("clawhub", func(_ string, cfg config.SkillRegistryConfig) RegistryProvider {
+ privateCfg := clawHubRegistryPrivateConfig{}
+ if err := cfg.DecodeParam(&privateCfg); err != nil {
+ slog.Warn("invalid clawhub private config", "error", err)
+ }
+ return ClawHubConfig{
+ Enabled: cfg.Enabled,
+ BaseURL: cfg.BaseURL,
+ AuthToken: cfg.AuthToken.String(),
+ SearchPath: privateCfg.SearchPath,
+ SkillsPath: privateCfg.SkillsPath,
+ DownloadPath: privateCfg.DownloadPath,
+ Timeout: privateCfg.Timeout,
+ MaxZipSize: privateCfg.MaxZipSize,
+ MaxResponseSize: privateCfg.MaxResponseSize,
+ }
+ })
+}
+
+type clawHubRegistryPrivateConfig struct {
+ SearchPath string `json:"search_path"`
+ SkillsPath string `json:"skills_path"`
+ DownloadPath string `json:"download_path"`
+ Timeout int `json:"timeout"`
+ MaxZipSize int `json:"max_zip_size"`
+ MaxResponseSize int `json:"max_response_size"`
+}
+
// ClawHubRegistry implements SkillRegistry for the ClawHub platform.
type ClawHubRegistry struct {
baseURL string
@@ -88,6 +119,28 @@ func (c *ClawHubRegistry) Name() string {
return "clawhub"
}
+func (c *ClawHubRegistry) ResolveInstallDirName(target string) (string, error) {
+ if err := utils.ValidateSkillIdentifier(target); err != nil {
+ return "", err
+ }
+ return target, nil
+}
+
+func (c *ClawHubRegistry) SkillURL(slug, _ string) string {
+ if slug == "" {
+ return ""
+ }
+ return c.baseURL + "/skills/" + url.PathEscape(slug)
+}
+
+func (c ClawHubConfig) IsEnabled() bool {
+ return c.Enabled
+}
+
+func (c ClawHubConfig) BuildRegistry() SkillRegistry {
+ return NewClawHubRegistry(c)
+}
+
// --- Search ---
type clawhubSearchResponse struct {
diff --git a/pkg/skills/config_bridge.go b/pkg/skills/config_bridge.go
new file mode 100644
index 000000000..5302db196
--- /dev/null
+++ b/pkg/skills/config_bridge.go
@@ -0,0 +1,136 @@
+package skills
+
+import "github.com/sipeed/picoclaw/pkg/config"
+
+const defaultGitHubRegistryBaseURL = "https://github.com"
+
+func effectiveRegistryConfigsFromToolsConfig(cfg config.SkillsToolsConfig) []config.SkillRegistryConfig {
+ effective := make([]config.SkillRegistryConfig, 0, len(cfg.Registries)+1)
+ seen := map[string]struct{}{}
+
+ for _, registryCfg := range cfg.Registries {
+ if registryCfg == nil || registryCfg.Name == "" {
+ continue
+ }
+ resolved := *registryCfg
+ if resolved.Name == "github" {
+ resolved = applyLegacyGithubRegistryCompatibility(cfg, resolved)
+ }
+ effective = append(effective, resolved)
+ seen[resolved.Name] = struct{}{}
+ }
+
+ if _, ok := seen["github"]; ok {
+ return effective
+ }
+
+ legacyGithubConfigured := cfg.Github.BaseURL != "" || cfg.Github.Token.String() != "" || cfg.Github.Proxy != ""
+ if !legacyGithubConfigured {
+ return effective
+ }
+
+ effective = append(effective, applyLegacyGithubRegistryCompatibility(cfg, config.SkillRegistryConfig{
+ Name: "github",
+ Enabled: true,
+ }))
+ return effective
+}
+
+func applyLegacyGithubRegistryCompatibility(
+ cfg config.SkillsToolsConfig,
+ registryCfg config.SkillRegistryConfig,
+) config.SkillRegistryConfig {
+ if registryCfg.Name != "github" {
+ return registryCfg
+ }
+ if registryCfg.Param == nil {
+ registryCfg.Param = map[string]any{}
+ }
+ if registryCfg.BaseURL == "" ||
+ (registryCfg.BaseURL == defaultGitHubRegistryBaseURL &&
+ cfg.Github.BaseURL != "" &&
+ cfg.Github.BaseURL != defaultGitHubRegistryBaseURL) {
+ registryCfg.BaseURL = cfg.Github.BaseURL
+ }
+ if registryCfg.AuthToken.String() == "" {
+ registryCfg.AuthToken = cfg.Github.Token
+ }
+ if _, ok := registryCfg.Param["proxy"]; !ok && cfg.Github.Proxy != "" {
+ registryCfg.Param["proxy"] = cfg.Github.Proxy
+ }
+ return registryCfg
+}
+
+func registryProvidersFromToolsConfig(cfg config.SkillsToolsConfig) []RegistryProvider {
+ registryConfigs := effectiveRegistryConfigsFromToolsConfig(cfg)
+ providers := make([]RegistryProvider, 0, len(registryConfigs))
+ for _, registryCfg := range registryConfigs {
+ provider := buildRegistryProvider(registryCfg.Name, registryCfg)
+ if provider == nil {
+ continue
+ }
+ providers = append(providers, provider)
+ }
+ return providers
+}
+
+func NewRegistryManagerFromToolsConfig(cfg config.SkillsToolsConfig) *RegistryManager {
+ return NewRegistryManagerFromConfig(RegistryConfig{
+ Providers: registryProvidersFromToolsConfig(cfg),
+ MaxConcurrentSearches: cfg.MaxConcurrentSearches,
+ })
+}
+
+func LookupRegistryFromToolsConfig(cfg config.SkillsToolsConfig, name string) SkillRegistry {
+ for _, provider := range registryProvidersFromToolsConfig(cfg) {
+ if provider == nil {
+ continue
+ }
+ registry := provider.BuildRegistry()
+ if registry == nil || registry.Name() != name {
+ continue
+ }
+ return registry
+ }
+ return nil
+}
+
+func GitHubInstallDirNameFromToolsConfig(cfg config.SkillsToolsConfig, target string) (string, error) {
+ registryCfg, ok := cfg.Registries.Get("github")
+ if ok {
+ registryCfg = applyLegacyGithubRegistryCompatibility(cfg, registryCfg)
+ return githubInstallDirNameWithBaseURL(target, registryCfg.BaseURL)
+ }
+ return githubInstallDirNameWithBaseURL(target, cfg.Github.BaseURL)
+}
+
+func NormalizeInstallTargetForRegistry(cfg config.SkillsToolsConfig, registryName, target string) string {
+ if registryName == "" || target == "" {
+ return target
+ }
+ registry := LookupRegistryFromToolsConfig(cfg, registryName)
+ if registry == nil {
+ return target
+ }
+ ghRegistry, ok := registry.(*GitHubRegistry)
+ if !ok {
+ return target
+ }
+ normalized, err := canonicalGitHubRegistrySlugWithBaseURL(target, ghRegistry.webBase)
+ if err != nil || normalized == "" {
+ return target
+ }
+ return normalized
+}
+
+func BuildInstallMetadataForRegistryInstance(registry SkillRegistry, target, version string) (string, string) {
+ normalizedTarget := NormalizeInstallTargetForRegistryInstance(registry, target)
+ if registry == nil {
+ return normalizedTarget, ""
+ }
+ registryURL := registry.SkillURL(target, version)
+ if registryURL == "" {
+ registryURL = registry.SkillURL(normalizedTarget, version)
+ }
+ return normalizedTarget, registryURL
+}
diff --git a/pkg/skills/github_registry.go b/pkg/skills/github_registry.go
new file mode 100644
index 000000000..de2dd9697
--- /dev/null
+++ b/pkg/skills/github_registry.go
@@ -0,0 +1,305 @@
+package skills
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "path"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ RegisterRegistryProviderBuilder("github", func(_ string, cfg config.SkillRegistryConfig) RegistryProvider {
+ privateCfg := githubRegistryPrivateConfig{}
+ if err := cfg.DecodeParam(&privateCfg); err != nil {
+ slog.Warn("invalid github private config", "error", err)
+ }
+ return GitHubRegistryConfig{
+ Enabled: cfg.Enabled,
+ BaseURL: cfg.BaseURL,
+ AuthToken: cfg.AuthToken.String(),
+ Proxy: privateCfg.Proxy,
+ }
+ })
+}
+
+type githubRegistryPrivateConfig struct {
+ Proxy string `json:"proxy"`
+}
+
+type GitHubRegistryConfig struct {
+ Enabled bool
+ BaseURL string
+ AuthToken string
+ Proxy string
+}
+
+type GitHubRegistry struct {
+ installer *SkillInstaller
+ webBase string
+}
+
+const githubAuthTokenHelp = "configure registries.github.auth_token"
+
+func (c GitHubRegistryConfig) IsEnabled() bool {
+ return c.Enabled
+}
+
+func (c GitHubRegistryConfig) BuildRegistry() SkillRegistry {
+ installer, err := NewSkillInstallerWithBaseURL("", c.BaseURL, c.AuthToken, c.Proxy)
+ if err != nil {
+ slog.Warn("failed to create github registry installer", "error", err)
+ return nil
+ }
+ return &GitHubRegistry{
+ installer: installer,
+ webBase: installer.githubBaseURL,
+ }
+}
+
+func (r *GitHubRegistry) Name() string {
+ return "github"
+}
+
+func (r *GitHubRegistry) ResolveInstallDirName(target string) (string, error) {
+ return githubInstallDirNameWithBaseURL(target, r.webBase)
+}
+
+func (r *GitHubRegistry) NormalizeInstallTarget(target string) string {
+ normalized, err := canonicalGitHubRegistrySlugWithBaseURL(target, r.webBase)
+ if err != nil {
+ return target
+ }
+ return normalized
+}
+
+func (r *GitHubRegistry) SkillURL(target, version string) string {
+ defaultRef := strings.TrimSpace(version)
+ parsedTarget, err := parseGitHubTargetWithBaseURL(target, r.webBase, defaultRef)
+ if err != nil {
+ return ""
+ }
+ ref := parsedTarget.Ref
+ base := strings.TrimRight(parsedTarget.Endpoints.WebBaseURL, "/")
+ urlPath := path.Join(ref.Owner, ref.RepoName)
+ if ref.SubPath != "" {
+ if ref.Ref == "" {
+ return ""
+ }
+ viewKind := "tree"
+ if isSkillMarkdownPath(ref.SubPath) {
+ viewKind = "blob"
+ }
+ return fmt.Sprintf("%s/%s/%s/%s/%s", base, urlPath, viewKind, ref.Ref, ref.SubPath)
+ }
+ if ref.Ref == "" {
+ return fmt.Sprintf("%s/%s", base, urlPath)
+ }
+ if ref.Ref != "main" {
+ return fmt.Sprintf("%s/%s/tree/%s", base, urlPath, ref.Ref)
+ }
+ return fmt.Sprintf("%s/%s", base, urlPath)
+}
+
+type gitHubCodeSearchResponse struct {
+ Items []gitHubCodeSearchItem `json:"items"`
+}
+
+type gitHubCodeSearchItem struct {
+ Path string `json:"path"`
+ HTMLURL string `json:"html_url"`
+ Score float64 `json:"score"`
+ Repository struct {
+ FullName string `json:"full_name"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ DefaultBranch string `json:"default_branch"`
+ } `json:"repository"`
+}
+
+func (r *GitHubRegistry) Search(ctx context.Context, query string, limit int) ([]SearchResult, error) {
+ query = strings.TrimSpace(query)
+ if query == "" {
+ return nil, nil
+ }
+ if limit <= 0 {
+ limit = 5
+ }
+
+ u, err := url.Parse(strings.TrimRight(r.installer.githubAPIBaseURL, "/") + "/search/code")
+ if err != nil {
+ return nil, fmt.Errorf("invalid github api base url: %w", err)
+ }
+ q := u.Query()
+ q.Set("q", fmt.Sprintf("%s filename:SKILL.md", query))
+ q.Set("per_page", fmt.Sprintf("%d", limit))
+ u.RawQuery = q.Encode()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/vnd.github+json")
+ if r.installer.githubToken != "" {
+ req.Header.Set("Authorization", "Bearer "+r.installer.githubToken)
+ }
+
+ resp, err := r.installer.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
+ if err != nil {
+ return nil, fmt.Errorf("failed to read github search response: %w", err)
+ }
+ if resp.StatusCode == http.StatusUnauthorized && r.installer.githubToken == "" && isGitHubAuthRequiredError(body) {
+ slog.Warn("github search requires authentication; returning no results", "help", githubAuthTokenHelp)
+ return []SearchResult{}, nil
+ }
+ if resp.StatusCode == http.StatusForbidden && r.installer.githubToken == "" && isGitHubRateLimitError(body) {
+ slog.Warn("github search hit unauthenticated rate limit; returning no results", "help", githubAuthTokenHelp)
+ return []SearchResult{}, nil
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("github search failed: HTTP %d: %s", resp.StatusCode, string(body))
+ }
+
+ var parsed gitHubCodeSearchResponse
+ if err := json.Unmarshal(body, &parsed); err != nil {
+ return nil, fmt.Errorf("failed to parse github search response: %w", err)
+ }
+
+ resultsBySlug := map[string]SearchResult{}
+ for _, item := range parsed.Items {
+ slug, ok := githubSearchSlug(item)
+ if !ok {
+ continue
+ }
+ result := SearchResult{
+ Score: item.Score,
+ Slug: slug,
+ DisplayName: githubSearchDisplayName(item),
+ Summary: strings.TrimSpace(item.Repository.Description),
+ Version: strings.TrimSpace(item.Repository.DefaultBranch),
+ RegistryName: r.Name(),
+ }
+ if existing, exists := resultsBySlug[slug]; exists && existing.Score >= result.Score {
+ continue
+ }
+ resultsBySlug[slug] = result
+ }
+
+ results := make([]SearchResult, 0, len(resultsBySlug))
+ for _, result := range resultsBySlug {
+ results = append(results, result)
+ }
+ sort.Slice(results, func(i, j int) bool {
+ if results[i].Score == results[j].Score {
+ return results[i].Slug < results[j].Slug
+ }
+ return results[i].Score > results[j].Score
+ })
+ if len(results) > limit {
+ results = results[:limit]
+ }
+ return results, nil
+}
+
+func isGitHubRateLimitError(body []byte) bool {
+ message := strings.ToLower(string(body))
+ return strings.Contains(message, "rate limit exceeded")
+}
+
+func isGitHubAuthRequiredError(body []byte) bool {
+ message := strings.ToLower(string(body))
+ return strings.Contains(message, "requires authentication") ||
+ strings.Contains(message, "must be authenticated to access the code search api")
+}
+
+func githubSearchSlug(item gitHubCodeSearchItem) (string, bool) {
+ fullName := strings.TrimSpace(item.Repository.FullName)
+ if fullName == "" {
+ return "", false
+ }
+ cleanPath := strings.Trim(strings.TrimSpace(item.Path), "/")
+ if cleanPath == "" || filepath.Base(cleanPath) != "SKILL.md" {
+ return "", false
+ }
+ dir := path.Dir(cleanPath)
+ if dir == "." || dir == "" {
+ return fullName, true
+ }
+ return fullName + "/" + dir, true
+}
+
+func githubSearchDisplayName(item gitHubCodeSearchItem) string {
+ cleanPath := strings.Trim(strings.TrimSpace(item.Path), "/")
+ if cleanPath != "" {
+ dir := path.Dir(cleanPath)
+ if dir != "." && dir != "" {
+ return path.Base(dir)
+ }
+ }
+ if name := strings.TrimSpace(item.Repository.Name); name != "" {
+ return name
+ }
+ return strings.TrimSpace(item.Repository.FullName)
+}
+
+func canonicalGitHubRegistrySlugWithBaseURL(target, githubBaseURL string) (string, error) {
+ ref, err := parseGitHubRefWithBaseURL(target, githubBaseURL, "")
+ if err != nil {
+ return "", err
+ }
+ slug := path.Join(ref.Owner, ref.RepoName)
+ if ref.SubPath != "" {
+ slug = path.Join(slug, ref.SubPath)
+ }
+ return slug, nil
+}
+
+func (r *GitHubRegistry) GetSkillMeta(ctx context.Context, target string) (*SkillMeta, error) {
+ slug, err := canonicalGitHubRegistrySlugWithBaseURL(target, r.webBase)
+ if err != nil {
+ return nil, err
+ }
+ parsedTarget, err := parseGitHubTargetWithBaseURL(target, r.webBase, "")
+ if err != nil {
+ return nil, err
+ }
+ ref := parsedTarget.Ref
+ if ref.Ref == "" {
+ ref.Ref, err = r.installer.fetchDefaultBranchWithAPIBaseURL(
+ ctx,
+ parsedTarget.Endpoints.APIBaseURL,
+ ref.Owner,
+ ref.RepoName,
+ )
+ if err != nil {
+ return nil, err
+ }
+ }
+ return &SkillMeta{
+ Slug: slug,
+ DisplayName: ref.RepoName,
+ LatestVersion: ref.Ref,
+ RegistryName: r.Name(),
+ }, nil
+}
+
+func (r *GitHubRegistry) DownloadAndInstall(
+ ctx context.Context,
+ target, version, targetDir string,
+) (*InstallResult, error) {
+ return r.installer.InstallFromGitHubToDir(ctx, target, version, targetDir)
+}
diff --git a/pkg/skills/github_registry_test.go b/pkg/skills/github_registry_test.go
new file mode 100644
index 000000000..3ac309700
--- /dev/null
+++ b/pkg/skills/github_registry_test.go
@@ -0,0 +1,218 @@
+package skills
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestGitHubRegistrySearch(t *testing.T) {
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/api/v3/search/code", r.URL.Path)
+ assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
+ assert.Equal(t, "skill search filename:SKILL.md", r.URL.Query().Get("q"))
+ assert.Equal(t, "2", r.URL.Query().Get("per_page"))
+
+ w.Header().Set("Content-Type", "application/json")
+ require.NoError(t, json.NewEncoder(w).Encode(gitHubCodeSearchResponse{
+ Items: []gitHubCodeSearchItem{
+ {
+ Path: "skills/pr-review/SKILL.md",
+ Score: 10,
+ HTMLURL: server.URL + "/foo/bar/blob/main/skills/pr-review/SKILL.md",
+ Repository: struct {
+ FullName string `json:"full_name"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ DefaultBranch string `json:"default_branch"`
+ }{
+ FullName: "foo/bar",
+ Name: "bar",
+ Description: "Review pull requests",
+ DefaultBranch: "main",
+ },
+ },
+ {
+ Path: "SKILL.md",
+ Score: 5,
+ HTMLURL: server.URL + "/foo/root/blob/main/SKILL.md",
+ Repository: struct {
+ FullName string `json:"full_name"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ DefaultBranch string `json:"default_branch"`
+ }{
+ FullName: "foo/root",
+ Name: "root",
+ Description: "Root skill",
+ DefaultBranch: "master",
+ },
+ },
+ },
+ }))
+ }))
+ defer server.Close()
+
+ provider := GitHubRegistryConfig{
+ Enabled: true,
+ BaseURL: server.URL,
+ AuthToken: "test-token",
+ }
+ registry := provider.BuildRegistry()
+ require.NotNil(t, registry)
+
+ results, err := registry.Search(context.Background(), "skill search", 2)
+ require.NoError(t, err)
+ require.Len(t, results, 2)
+
+ assert.Equal(t, "foo/bar/skills/pr-review", results[0].Slug)
+ assert.Equal(t, "pr-review", results[0].DisplayName)
+ assert.Equal(t, "Review pull requests", results[0].Summary)
+ assert.Equal(t, "main", results[0].Version)
+ assert.Equal(t, "github", results[0].RegistryName)
+
+ assert.Equal(t, "foo/root", results[1].Slug)
+ assert.Equal(t, "root", results[1].DisplayName)
+ assert.Equal(t, "master", results[1].Version)
+}
+
+func TestGitHubRegistryProviderDecodesProxyParam(t *testing.T) {
+ builder := buildRegistryProvider("github", config.SkillRegistryConfig{
+ Name: "github",
+ Enabled: true,
+ BaseURL: "https://github.com",
+ AuthToken: *config.NewSecureString("test-token"),
+ Param: map[string]any{
+ "proxy": "http://127.0.0.1:7890",
+ },
+ })
+ require.NotNil(t, builder)
+
+ registry := builder.BuildRegistry()
+ require.NotNil(t, registry)
+ ghRegistry, ok := registry.(*GitHubRegistry)
+ require.True(t, ok)
+ assert.Equal(t, "http://127.0.0.1:7890", ghRegistry.installer.proxy)
+}
+
+func TestGitHubRegistrySearchReturnsNoResultsOnUnauthenticatedRateLimit(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Empty(t, r.Header.Get("Authorization"))
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte(`{"message":"API rate limit exceeded for 1.2.3.4"}`))
+ }))
+ defer server.Close()
+
+ registry := GitHubRegistryConfig{Enabled: true, BaseURL: server.URL}.BuildRegistry()
+ require.NotNil(t, registry)
+
+ results, err := registry.Search(context.Background(), "pr review", 5)
+ require.NoError(t, err)
+ assert.Empty(t, results)
+}
+
+func TestGitHubRegistrySearchReturnsNoResultsOnUnauthenticatedAuthRequired(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Empty(t, r.Header.Get("Authorization"))
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(
+ `{"message":"Requires authentication","errors":[{"message":"Must be authenticated to access the code search API"}]}`,
+ ))
+ }))
+ defer server.Close()
+
+ registry := GitHubRegistryConfig{Enabled: true, BaseURL: server.URL}.BuildRegistry()
+ require.NotNil(t, registry)
+
+ results, err := registry.Search(context.Background(), "pr review", 5)
+ require.NoError(t, err)
+ assert.Empty(t, results)
+}
+
+func TestGitHubRegistryGetSkillMetaCanonicalizesURLSlug(t *testing.T) {
+ registry := GitHubRegistryConfig{
+ Enabled: true,
+ BaseURL: "https://ghe.example.com/git",
+ }.BuildRegistry()
+ require.NotNil(t, registry)
+
+ meta, err := registry.GetSkillMeta(
+ context.Background(),
+ "https://ghe.example.com/git/org/repo/tree/dev/skills/pr-review",
+ )
+ require.NoError(t, err)
+ require.NotNil(t, meta)
+ assert.Equal(t, "org/repo/skills/pr-review", meta.Slug)
+ assert.Equal(t, "dev", meta.LatestVersion)
+}
+
+func TestGitHubRegistrySkillURLUsesProvidedVersionAndBasePath(t *testing.T) {
+ registry := GitHubRegistryConfig{
+ Enabled: true,
+ BaseURL: "https://ghe.example.com/git",
+ }.BuildRegistry()
+ require.NotNil(t, registry)
+
+ assert.Equal(
+ t,
+ "https://ghe.example.com/git/org/repo/tree/master/skills/pr-review",
+ registry.SkillURL("org/repo/skills/pr-review", "master"),
+ )
+ assert.Equal(
+ t,
+ "https://ghe.example.com/git/org/repo/tree/dev/skills/pr-review",
+ registry.SkillURL("https://ghe.example.com/git/org/repo/tree/dev/skills/pr-review", ""),
+ )
+ assert.Equal(
+ t,
+ "https://ghe.example.com/git/org/repo/tree/feature/skills-registry/skills/pr-review",
+ registry.SkillURL("org/repo/skills/pr-review", "feature/skills-registry"),
+ )
+ assert.Equal(
+ t,
+ "https://ghe.example.com/git/org/repo/blob/main/.agents/skills/pr-review/SKILL.md",
+ registry.SkillURL("https://ghe.example.com/git/org/repo/blob/main/.agents/skills/pr-review/SKILL.md", ""),
+ )
+ assert.Equal(
+ t,
+ "https://github.com/org/repo/tree/main/.agents/skills/pr-review",
+ registry.SkillURL("https://github.com/org/repo/tree/main/.agents/skills/pr-review", ""),
+ )
+ assert.Empty(t, registry.SkillURL("org/repo/.agents/skills/pr-review", ""))
+}
+
+func TestGitHubRegistryResolveInstallDirNameSupportsFullURLs(t *testing.T) {
+ registry := GitHubRegistryConfig{
+ Enabled: true,
+ BaseURL: "https://ghe.example.com/git",
+ }.BuildRegistry()
+ require.NotNil(t, registry)
+
+ dirName, err := registry.ResolveInstallDirName("https://ghe.example.com/git/org/repo/tree/dev/skills/pr-review")
+ require.NoError(t, err)
+ assert.Equal(t, "pr-review", dirName)
+
+ dirName, err = registry.ResolveInstallDirName("https://github.com/org/repo/tree/main/skills/release-checklist")
+ require.NoError(t, err)
+ assert.Equal(t, "release-checklist", dirName)
+
+ dirName, err = registry.ResolveInstallDirName(
+ "https://ghe.example.com/git/org/repo/blob/dev/skills/pr-review/SKILL.md",
+ )
+ require.NoError(t, err)
+ assert.Equal(t, "pr-review", dirName)
+
+ dirName, err = registry.ResolveInstallDirName(
+ "https://ghe.example.com/git/org/repo/blob/dev/SKILL.md",
+ )
+ require.NoError(t, err)
+ assert.Equal(t, "repo", dirName)
+}
diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go
index f6cdee3a6..2f97ca8bf 100644
--- a/pkg/skills/installer.go
+++ b/pkg/skills/installer.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "io"
"net/http"
"net/url"
"os"
@@ -12,6 +13,7 @@ import (
"strings"
"time"
+ "github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/utils"
)
@@ -32,110 +34,434 @@ type GitHubRef struct {
SubPath string // Path within the repository
}
+type gitHubTarget struct {
+ Ref GitHubRef
+ Endpoints gitHubEndpoints
+}
+
type SkillInstaller struct {
- workspace string
- client *http.Client
- githubToken string
- proxy string
+ workspace string
+ client *http.Client
+ githubBaseURL string
+ githubAPIBaseURL string
+ githubRawBaseURL string
+ githubToken string
+ proxy string
}
// NewSkillInstaller creates a new skill installer.
// proxy is an optional HTTP/HTTPS/SOCKS5 proxy URL for downloading skills.
func NewSkillInstaller(workspace, githubToken, proxy string) (*SkillInstaller, error) {
+ return NewSkillInstallerWithBaseURL(workspace, "", githubToken, proxy)
+}
+
+// NewSkillInstallerWithBaseURL creates a new skill installer with a custom GitHub base URL.
+// For github.com this can be left empty. For GitHub Enterprise, set it to the web URL.
+func NewSkillInstallerWithBaseURL(workspace, githubBaseURL, githubToken, proxy string) (*SkillInstaller, error) {
client, err := utils.CreateHTTPClient(proxy, 15*time.Second)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
}
+ endpoints, err := resolveGitHubEndpoints(githubBaseURL)
+ if err != nil {
+ return nil, err
+ }
return &SkillInstaller{
- workspace: workspace,
- client: client,
- githubToken: githubToken,
- proxy: proxy,
+ workspace: workspace,
+ client: client,
+ githubBaseURL: endpoints.WebBaseURL,
+ githubAPIBaseURL: endpoints.APIBaseURL,
+ githubRawBaseURL: endpoints.RawBaseURL,
+ githubToken: githubToken,
+ proxy: proxy,
}, nil
}
+type gitHubEndpoints struct {
+ WebBaseURL string
+ APIBaseURL string
+ RawBaseURL string
+}
+
+func resolveGitHubEndpoints(baseURL string) (gitHubEndpoints, error) {
+ trimmed := strings.TrimSpace(baseURL)
+ if trimmed == "" {
+ return gitHubEndpoints{
+ WebBaseURL: "https://github.com",
+ APIBaseURL: "https://api.github.com",
+ RawBaseURL: "https://raw.githubusercontent.com",
+ }, nil
+ }
+
+ u, err := url.Parse(trimmed)
+ if err != nil {
+ return gitHubEndpoints{}, fmt.Errorf("invalid github base url: %w", err)
+ }
+ if u.Scheme == "" || u.Host == "" {
+ return gitHubEndpoints{}, fmt.Errorf("invalid github base url %q", baseURL)
+ }
+
+ trimmedPath := strings.TrimSuffix(u.Path, "/")
+ origin := u.Scheme + "://" + u.Host
+
+ if u.Host == "api.github.com" {
+ return gitHubEndpoints{
+ WebBaseURL: "https://github.com",
+ APIBaseURL: "https://api.github.com",
+ RawBaseURL: "https://raw.githubusercontent.com",
+ }, nil
+ }
+
+ if strings.HasSuffix(trimmedPath, "/api/v3") {
+ webBaseURL := origin + strings.TrimSuffix(trimmedPath, "/api/v3")
+ webBaseURL = strings.TrimSuffix(webBaseURL, "/")
+ if webBaseURL == origin {
+ webBaseURL = origin
+ }
+ return gitHubEndpoints{
+ WebBaseURL: webBaseURL,
+ APIBaseURL: origin + trimmedPath,
+ RawBaseURL: webBaseURL + "/raw",
+ }, nil
+ }
+
+ webBaseURL := origin + trimmedPath
+ webBaseURL = strings.TrimSuffix(webBaseURL, "/")
+ if u.Host == "github.com" {
+ return gitHubEndpoints{
+ WebBaseURL: "https://github.com",
+ APIBaseURL: "https://api.github.com",
+ RawBaseURL: "https://raw.githubusercontent.com",
+ }, nil
+ }
+
+ return gitHubEndpoints{
+ WebBaseURL: webBaseURL,
+ APIBaseURL: webBaseURL + "/api/v3",
+ RawBaseURL: webBaseURL + "/raw",
+ }, nil
+}
+
+func parseGitHubRefPathParts(repoURL *url.URL, githubBaseURL string) []string {
+ parts := strings.Split(strings.Trim(repoURL.Path, "/"), "/")
+ if len(parts) == 0 {
+ return parts
+ }
+ if githubBaseURL == "" {
+ return parts
+ }
+ baseURL, err := url.Parse(strings.TrimSpace(githubBaseURL))
+ if err != nil {
+ return parts
+ }
+ if !strings.EqualFold(repoURL.Host, baseURL.Host) || !strings.EqualFold(repoURL.Scheme, baseURL.Scheme) {
+ return parts
+ }
+ baseParts := strings.Split(strings.Trim(baseURL.Path, "/"), "/")
+ if len(baseParts) == 1 && baseParts[0] == "" {
+ baseParts = nil
+ }
+ if len(baseParts) == 0 || len(parts) < len(baseParts)+2 {
+ return parts
+ }
+ for i, part := range baseParts {
+ if parts[i] != part {
+ return parts
+ }
+ }
+ return parts[len(baseParts):]
+}
+
+func supportedGitHubBaseURL(repoURL *url.URL, githubBaseURL string) string {
+ if repoURL == nil {
+ return ""
+ }
+ trimmedBaseURL := strings.TrimSpace(githubBaseURL)
+ if trimmedBaseURL != "" && matchesGitHubWebBase(repoURL, trimmedBaseURL) {
+ return trimmedBaseURL
+ }
+ if matchesGitHubWebBase(repoURL, "https://github.com") {
+ return "https://github.com"
+ }
+ return ""
+}
+
+func matchesGitHubWebBase(repoURL *url.URL, webBaseURL string) bool {
+ baseURL, err := url.Parse(strings.TrimSpace(webBaseURL))
+ if err != nil {
+ return false
+ }
+ if !strings.EqualFold(repoURL.Scheme, baseURL.Scheme) {
+ return false
+ }
+ if !strings.EqualFold(repoURL.Host, baseURL.Host) {
+ return false
+ }
+ basePath := strings.Trim(baseURL.Path, "/")
+ if basePath == "" {
+ return true
+ }
+ repoPath := strings.Trim(repoURL.Path, "/")
+ return repoPath == basePath || strings.HasPrefix(repoPath, basePath+"/")
+}
+
+func splitGitHubTreeOrBlobRefPath(parts []string, defaultRef string) (string, string) {
+ if len(parts) == 0 {
+ return defaultRef, ""
+ }
+ if anchor := knownSkillSubPathAnchor(parts); anchor > 0 {
+ return strings.Join(parts[:anchor], "/"), strings.Join(parts[anchor:], "/")
+ }
+ if parts[len(parts)-1] == "SKILL.md" {
+ return strings.Join(parts[:len(parts)-1], "/"), "SKILL.md"
+ }
+ return parts[0], strings.Join(parts[1:], "/")
+}
+
+func knownSkillSubPathAnchor(parts []string) int {
+ for i := 1; i < len(parts); i++ {
+ candidateSubPath := strings.Join(parts[i:], "/")
+ if strings.HasPrefix(candidateSubPath, ".agents/skills/") || strings.HasPrefix(candidateSubPath, "skills/") {
+ return i
+ }
+ }
+ return -1
+}
+
+func isSkillMarkdownPath(subPath string) bool {
+ subPath = strings.Trim(strings.TrimSpace(subPath), "/")
+ return subPath == "SKILL.md" || strings.HasSuffix(subPath, "/SKILL.md")
+}
+
// parseGitHubRef parses a GitHub reference.
// Supports: "owner/repo", "owner/repo/path", or full URL like "https://github.com/owner/repo/tree/ref/path"
func parseGitHubRef(repo string) (GitHubRef, error) {
+ return parseGitHubRefWithBaseURL(repo, "", "main")
+}
+
+func parseGitHubRefWithBaseURL(repo, githubBaseURL, defaultRef string) (GitHubRef, error) {
+ target, err := parseGitHubTargetWithBaseURL(repo, githubBaseURL, defaultRef)
+ if err != nil {
+ return GitHubRef{}, err
+ }
+ return target.Ref, nil
+}
+
+func parseGitHubTargetWithBaseURL(repo, githubBaseURL, defaultRef string) (gitHubTarget, error) {
repo = strings.TrimSpace(repo)
+ defaultRef = strings.TrimSpace(defaultRef)
// Handle full URL
if strings.HasPrefix(repo, "http://") || strings.HasPrefix(repo, "https://") {
u, err := url.Parse(repo)
if err != nil {
- return GitHubRef{}, fmt.Errorf("invalid URL: %w", err)
+ return gitHubTarget{}, fmt.Errorf("invalid URL: %w", err)
}
- parts := strings.Split(strings.Trim(u.Path, "/"), "/")
+ matchedBaseURL := supportedGitHubBaseURL(u, githubBaseURL)
+ if matchedBaseURL == "" {
+ return gitHubTarget{}, fmt.Errorf("invalid GitHub URL host %q", u.Host)
+ }
+ endpoints, err := resolveGitHubEndpoints(matchedBaseURL)
+ if err != nil {
+ return gitHubTarget{}, err
+ }
+ parts := parseGitHubRefPathParts(u, matchedBaseURL)
if len(parts) < 2 {
- return GitHubRef{}, fmt.Errorf("invalid GitHub URL")
+ return gitHubTarget{}, fmt.Errorf("invalid GitHub URL")
+ }
+ if len(parts) > 2 {
+ if parts[2] != "tree" && parts[2] != "blob" {
+ return gitHubTarget{}, fmt.Errorf("invalid GitHub repository URL path %q", u.Path)
+ }
+ if len(parts) < 4 {
+ return gitHubTarget{}, fmt.Errorf("invalid GitHub %s URL path %q", parts[2], u.Path)
+ }
}
ref := GitHubRef{
Owner: parts[0],
RepoName: parts[1],
- Ref: "main",
+ Ref: defaultRef,
}
// Look for /tree/ or /blob/ in the path
for i := 2; i < len(parts); i++ {
if parts[i] == "tree" || parts[i] == "blob" {
if i+1 < len(parts) {
- ref.Ref = parts[i+1]
- ref.SubPath = strings.Join(parts[i+2:], "/")
+ ref.Ref, ref.SubPath = splitGitHubTreeOrBlobRefPath(parts[i+1:], defaultRef)
}
break
}
}
- return ref, nil
+ return gitHubTarget{Ref: ref, Endpoints: endpoints}, nil
+ }
+
+ endpoints, err := resolveGitHubEndpoints(githubBaseURL)
+ if err != nil {
+ return gitHubTarget{}, err
}
// Handle shorthand format
parts := strings.Split(strings.Trim(repo, "/"), "/")
if len(parts) < 2 {
- return GitHubRef{}, fmt.Errorf("invalid format %q: expected 'owner/repo'", repo)
+ return gitHubTarget{}, fmt.Errorf("invalid format %q: expected 'owner/repo'", repo)
}
ref := GitHubRef{
Owner: parts[0],
RepoName: parts[1],
- Ref: "main",
+ Ref: defaultRef,
}
if len(parts) > 2 {
ref.SubPath = strings.Join(parts[2:], "/")
}
- return ref, nil
+ return gitHubTarget{Ref: ref, Endpoints: endpoints}, nil
+}
+
+type gitHubRepository struct {
+ DefaultBranch string `json:"default_branch"`
+}
+
+func (si *SkillInstaller) resolveGitHubTarget(ctx context.Context, repo, version string) (gitHubTarget, error) {
+ target, err := parseGitHubTargetWithBaseURL(repo, si.githubBaseURL, "")
+ if err != nil {
+ return gitHubTarget{}, err
+ }
+ if version != "" {
+ target.Ref.Ref = version
+ return target, nil
+ }
+ if target.Ref.Ref != "" {
+ return target, nil
+ }
+ defaultBranch, err := si.fetchDefaultBranchWithAPIBaseURL(
+ ctx,
+ target.Endpoints.APIBaseURL,
+ target.Ref.Owner,
+ target.Ref.RepoName,
+ )
+ if err != nil {
+ return gitHubTarget{}, err
+ }
+ target.Ref.Ref = defaultBranch
+ return target, nil
+}
+
+func (si *SkillInstaller) fetchDefaultBranchWithAPIBaseURL(
+ ctx context.Context,
+ apiBaseURL, owner, repo string,
+) (string, error) {
+ apiURL := fmt.Sprintf("%s/repos/%s/%s", strings.TrimRight(apiBaseURL, "/"), owner, repo)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
+ if err != nil {
+ return "", err
+ }
+ if si.githubToken != "" {
+ req.Header.Set("Authorization", "Bearer "+si.githubToken)
+ }
+
+ resp, err := utils.DoRequestWithRetry(si.client, req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return "", fmt.Errorf("failed to read repository metadata: %w", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("failed to resolve default branch: HTTP %d: %s", resp.StatusCode, string(body))
+ }
+
+ var repository gitHubRepository
+ if err := json.Unmarshal(body, &repository); err != nil {
+ return "", fmt.Errorf("failed to parse repository metadata: %w", err)
+ }
+ if strings.TrimSpace(repository.DefaultBranch) == "" {
+ return "", fmt.Errorf("repository %s/%s did not report a default branch", owner, repo)
+ }
+ return repository.DefaultBranch, nil
+}
+
+func githubInstallDirNameWithBaseURL(repo, githubBaseURL string) (string, error) {
+ if !strings.HasPrefix(repo, "http://") && !strings.HasPrefix(repo, "https://") {
+ if err := ValidateInstallTarget(repo); err != nil {
+ return "", err
+ }
+ }
+ ref, err := parseGitHubRefWithBaseURL(repo, githubBaseURL, "main")
+ if err != nil {
+ return "", err
+ }
+ if ref.SubPath != "" {
+ if isSkillMarkdownPath(ref.SubPath) {
+ skillDir := path.Dir(strings.Trim(ref.SubPath, "/"))
+ if skillDir == "." || skillDir == "" {
+ return ref.RepoName, nil
+ }
+ return path.Base(skillDir), nil
+ }
+ return filepath.Base(ref.SubPath), nil
+ }
+ return ref.RepoName, nil
}
func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) error {
- ref, err := parseGitHubRef(repo)
+ skillName, err := githubInstallDirNameWithBaseURL(repo, si.githubBaseURL)
if err != nil {
return err
}
-
- skillName := ref.RepoName
- if ref.SubPath != "" {
- skillName = filepath.Base(ref.SubPath)
- }
skillDirectory := filepath.Join(si.workspace, "skills", skillName)
- if _, err := os.Stat(skillDirectory); err == nil {
+ if _, statErr := os.Stat(skillDirectory); statErr == nil {
return fmt.Errorf("skill '%s' already exists", skillName)
}
+ _, err = si.InstallFromGitHubToDir(ctx, repo, "", skillDirectory)
+ return err
+}
+
+func (si *SkillInstaller) InstallFromGitHubToDir(
+ ctx context.Context,
+ repo, version, skillDirectory string,
+) (*InstallResult, error) {
+ target, err := si.resolveGitHubTarget(ctx, repo, version)
+ if err != nil {
+ return nil, err
+ }
+ ref := target.Ref
+ apiSubPath := strings.Trim(ref.SubPath, "/")
+ if isSkillMarkdownPath(apiSubPath) {
+ if dir := path.Dir(apiSubPath); dir == "." {
+ apiSubPath = ""
+ } else {
+ apiSubPath = dir
+ }
+ }
// Build GitHub API URL
apiPath := path.Join(ref.Owner, ref.RepoName, "contents")
- if ref.SubPath != "" {
- apiPath = path.Join(apiPath, ref.SubPath)
+ if apiSubPath != "" {
+ apiPath = path.Join(apiPath, apiSubPath)
}
- apiURL := fmt.Sprintf("https://api.github.com/repos/%s?ref=%s", apiPath, ref.Ref)
+ apiURL := fmt.Sprintf("%s/repos/%s?ref=%s", target.Endpoints.APIBaseURL, apiPath, url.QueryEscape(ref.Ref))
if err := si.getGithubDirAllFiles(ctx, apiURL, skillDirectory, true); err != nil {
// Fallback to raw download
- return si.downloadRaw(ctx, ref.Owner, ref.RepoName, ref.Ref, ref.SubPath, skillDirectory)
+ if downloadErr := si.downloadRaw(
+ ctx,
+ target.Endpoints.RawBaseURL,
+ ref.Owner,
+ ref.RepoName,
+ ref.Ref,
+ ref.SubPath,
+ skillDirectory,
+ ); downloadErr != nil {
+ return nil, downloadErr
+ }
+ } else if _, err := os.Stat(filepath.Join(skillDirectory, "SKILL.md")); err != nil {
+ return nil, fmt.Errorf("SKILL.md not found in repository")
}
- if _, err := os.Stat(filepath.Join(skillDirectory, "SKILL.md")); err != nil {
- return fmt.Errorf("SKILL.md not found in repository")
- }
- return nil
+ return &InstallResult{Version: ref.Ref}, nil
}
// downloadDir recursively downloads a directory from GitHub API
@@ -188,12 +514,19 @@ func (si *SkillInstaller) getGithubDirAllFiles(ctx context.Context, apiURL, loca
}
// downloadRaw is a fallback that downloads just SKILL.md from raw.githubusercontent.com
-func (si *SkillInstaller) downloadRaw(ctx context.Context, owner, repo, ref, subPath, localDir string) error {
+func (si *SkillInstaller) downloadRaw(
+ ctx context.Context,
+ rawBaseURL, owner, repo, ref, subPath, localDir string,
+) error {
urlPath := path.Join(owner, repo, ref)
if subPath != "" {
- urlPath = path.Join(urlPath, subPath)
+ if isSkillMarkdownPath(subPath) {
+ urlPath = strings.TrimSuffix(path.Join(urlPath, subPath), "/SKILL.md")
+ } else {
+ urlPath = path.Join(urlPath, subPath)
+ }
}
- url := fmt.Sprintf("https://raw.githubusercontent.com/%s/SKILL.md", urlPath)
+ url := fmt.Sprintf("%s/%s/SKILL.md", strings.TrimRight(rawBaseURL, "/"), urlPath)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
@@ -213,12 +546,10 @@ func (si *SkillInstaller) downloadRaw(ctx context.Context, owner, repo, ref, sub
localPath := filepath.Join(localDir, "SKILL.md")
- // Atomic move from temp to final location.
- if err := os.Rename(tmpPath, localPath); err != nil {
+ if err := fileutil.CopyFile(tmpPath, localPath, 0o600); err != nil {
return fmt.Errorf("failed to write skill file: %w", err)
}
-
- return os.Chmod(localPath, 0o600)
+ return nil
}
func (si *SkillInstaller) downloadFile(ctx context.Context, url, localPath string) error {
@@ -238,12 +569,10 @@ func (si *SkillInstaller) downloadFile(ctx context.Context, url, localPath strin
return err
}
- // Atomic move from temp to final location.
- if err := os.Rename(tmpPath, localPath); err != nil {
+ if err := fileutil.CopyFile(tmpPath, localPath, 0o600); err != nil {
return fmt.Errorf("failed to move downloaded file: %w", err)
}
-
- return os.Chmod(localPath, 0o600)
+ return nil
}
// shouldDownload determines if a file should be downloaded
diff --git a/pkg/skills/installer_test.go b/pkg/skills/installer_test.go
index 759cfc489..9691a5312 100644
--- a/pkg/skills/installer_test.go
+++ b/pkg/skills/installer_test.go
@@ -89,6 +89,12 @@ func TestParseGitHubRef(t *testing.T) {
wantRef: "main",
wantSubPath: "",
},
+ {
+ name: "invalid non github host",
+ repo: "https://gitlab.com/sipeed/picoclaw/-/tree/main/skills/test",
+ wantErr: true,
+ wantErrContain: `invalid GitHub URL host "gitlab.com"`,
+ },
}
for _, tt := range tests {
@@ -127,6 +133,268 @@ func TestParseGitHubRef(t *testing.T) {
}
}
+func TestParseGitHubRefWithBaseURL(t *testing.T) {
+ ref, err := parseGitHubRefWithBaseURL(
+ "https://ghe.example.com/git/org/repo/tree/dev/skills/test",
+ "https://ghe.example.com/git",
+ "main",
+ )
+ if err != nil {
+ t.Fatalf("parseGitHubRefWithBaseURL() unexpected error = %v", err)
+ }
+ if ref.Owner != "org" {
+ t.Fatalf("owner = %q, want org", ref.Owner)
+ }
+ if ref.RepoName != "repo" {
+ t.Fatalf("repo = %q, want repo", ref.RepoName)
+ }
+ if ref.Ref != "dev" {
+ t.Fatalf("ref = %q, want dev", ref.Ref)
+ }
+ if ref.SubPath != "skills/test" {
+ t.Fatalf("subPath = %q, want skills/test", ref.SubPath)
+ }
+
+ dirName, err := githubInstallDirNameWithBaseURL(
+ "https://ghe.example.com/git/org/repo/tree/dev/skills/test",
+ "https://ghe.example.com/git",
+ )
+ if err != nil {
+ t.Fatalf("githubInstallDirNameWithBaseURL() unexpected error = %v", err)
+ }
+ if dirName != "test" {
+ t.Fatalf("dirName = %q, want test", dirName)
+ }
+
+ dirName, err = githubInstallDirNameWithBaseURL(
+ "https://ghe.example.com/git/org/repo/blob/dev/skills/test/SKILL.md",
+ "https://ghe.example.com/git",
+ )
+ if err != nil {
+ t.Fatalf("githubInstallDirNameWithBaseURL() unexpected error for blob skill url = %v", err)
+ }
+ if dirName != "test" {
+ t.Fatalf("dirName for nested blob skill = %q, want test", dirName)
+ }
+
+ dirName, err = githubInstallDirNameWithBaseURL(
+ "https://ghe.example.com/git/org/repo/blob/dev/SKILL.md",
+ "https://ghe.example.com/git",
+ )
+ if err != nil {
+ t.Fatalf("githubInstallDirNameWithBaseURL() unexpected error for repo root blob skill = %v", err)
+ }
+ if dirName != "repo" {
+ t.Fatalf("dirName for repo root blob skill = %q, want repo", dirName)
+ }
+
+ ref, err = parseGitHubRefWithBaseURL("https://ghe.example.com/git/org/repo", "https://ghe.example.com/git", "")
+ if err != nil {
+ t.Fatalf("parseGitHubRefWithBaseURL() unexpected error = %v", err)
+ }
+ if ref.Ref != "" {
+ t.Fatalf("ref = %q, want empty", ref.Ref)
+ }
+
+ ref, err = parseGitHubRefWithBaseURL(
+ "https://github.com/org/repo/tree/feature/skills-registry/.agents/skills/pr-review",
+ "",
+ "main",
+ )
+ if err != nil {
+ t.Fatalf("parseGitHubRefWithBaseURL() unexpected error for slash branch = %v", err)
+ }
+ if ref.Ref != "feature/skills-registry" {
+ t.Fatalf("ref = %q, want feature/skills-registry", ref.Ref)
+ }
+ if ref.SubPath != ".agents/skills/pr-review" {
+ t.Fatalf("subPath = %q, want .agents/skills/pr-review", ref.SubPath)
+ }
+
+ _, err = parseGitHubRefWithBaseURL(
+ "https://gitlab.example.com/org/repo/-/tree/dev/skills/test",
+ "https://ghe.example.com/git",
+ "main",
+ )
+ if err == nil {
+ t.Fatal("parseGitHubRefWithBaseURL() error = nil, want invalid host error")
+ }
+ if !strings.Contains(err.Error(), `invalid GitHub URL host "gitlab.example.com"`) {
+ t.Fatalf("unexpected error = %v", err)
+ }
+
+ _, err = parseGitHubRefWithBaseURL(
+ "http://ghe.example.com/git/org/repo/tree/dev/skills/test",
+ "https://ghe.example.com/git",
+ "main",
+ )
+ if err == nil {
+ t.Fatal("parseGitHubRefWithBaseURL() error = nil, want invalid host error for scheme mismatch")
+ }
+ if !strings.Contains(err.Error(), `invalid GitHub URL host "ghe.example.com"`) {
+ t.Fatalf("unexpected scheme mismatch error = %v", err)
+ }
+
+ _, err = parseGitHubRefWithBaseURL(
+ "https://github.com/org/repo/pull/2442",
+ "",
+ "main",
+ )
+ if err == nil {
+ t.Fatal("parseGitHubRefWithBaseURL() error = nil, want invalid repository URL path error")
+ }
+ if !strings.Contains(err.Error(), `invalid GitHub repository URL path "/org/repo/pull/2442"`) {
+ t.Fatalf("unexpected PR URL error = %v", err)
+ }
+
+ _, err = parseGitHubRefWithBaseURL(
+ "https://github.com/org/repo/tree",
+ "",
+ "main",
+ )
+ if err == nil {
+ t.Fatal("parseGitHubRefWithBaseURL() error = nil, want invalid tree URL path error")
+ }
+ if !strings.Contains(err.Error(), `invalid GitHub tree URL path "/org/repo/tree"`) {
+ t.Fatalf("unexpected short tree URL error = %v", err)
+ }
+}
+
+func TestParseGitHubTargetWithBaseURLPreservesSourceEndpoints(t *testing.T) {
+ target, err := parseGitHubTargetWithBaseURL(
+ "https://github.com/org/repo/tree/main/.agents/skills/pr-review",
+ "https://ghe.example.com/git",
+ "",
+ )
+ if err != nil {
+ t.Fatalf("parseGitHubTargetWithBaseURL() unexpected error = %v", err)
+ }
+ if target.Endpoints.WebBaseURL != "https://github.com" {
+ t.Fatalf("web base = %q, want https://github.com", target.Endpoints.WebBaseURL)
+ }
+ if target.Endpoints.APIBaseURL != "https://api.github.com" {
+ t.Fatalf("api base = %q, want https://api.github.com", target.Endpoints.APIBaseURL)
+ }
+ if target.Endpoints.RawBaseURL != "https://raw.githubusercontent.com" {
+ t.Fatalf("raw base = %q, want https://raw.githubusercontent.com", target.Endpoints.RawBaseURL)
+ }
+ if target.Ref.Owner != "org" || target.Ref.RepoName != "repo" {
+ t.Fatalf("unexpected ref = %+v", target.Ref)
+ }
+ if target.Ref.Ref != "main" {
+ t.Fatalf("ref = %q, want main", target.Ref.Ref)
+ }
+ if target.Ref.SubPath != ".agents/skills/pr-review" {
+ t.Fatalf("subPath = %q, want .agents/skills/pr-review", target.Ref.SubPath)
+ }
+}
+
+func TestParseGitHubTargetWithBaseURLPreservesSlashBranchForRepoRootBlobSkill(t *testing.T) {
+ target, err := parseGitHubTargetWithBaseURL(
+ "https://github.com/org/repo/blob/feature/skills-registry/SKILL.md",
+ "",
+ "",
+ )
+ if err != nil {
+ t.Fatalf("parseGitHubTargetWithBaseURL() unexpected error = %v", err)
+ }
+ if target.Ref.Ref != "feature/skills-registry" {
+ t.Fatalf("ref = %q, want feature/skills-registry", target.Ref.Ref)
+ }
+ if target.Ref.SubPath != "SKILL.md" {
+ t.Fatalf("subPath = %q, want SKILL.md", target.Ref.SubPath)
+ }
+}
+
+func TestSkillInstallerResolveGitHubRefUsesDefaultBranch(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v3/repos/org/repo":
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"default_branch":"master"}`))
+ default:
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ }))
+ defer server.Close()
+
+ installer, err := NewSkillInstallerWithBaseURL(t.TempDir(), server.URL, "", "")
+ if err != nil {
+ t.Fatalf("NewSkillInstallerWithBaseURL() error = %v", err)
+ }
+
+ target, err := installer.resolveGitHubTarget(context.Background(), "org/repo/skills/test", "")
+ if err != nil {
+ t.Fatalf("resolveGitHubTarget() error = %v", err)
+ }
+ ref := target.Ref
+ if ref.Ref != "master" {
+ t.Fatalf("ref = %q, want master", ref.Ref)
+ }
+ if ref.SubPath != "skills/test" {
+ t.Fatalf("subPath = %q, want skills/test", ref.SubPath)
+ }
+}
+
+func TestSkillInstallerInstallFromGitHubToDirSupportsBlobSkillURL(t *testing.T) {
+ tmpDir := t.TempDir()
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v3/repos/org/repo/contents/.agents/skills/pr-review":
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`[
+ {"type":"file","name":"SKILL.md","download_url":"` + server.URL + `/raw/org/repo/main/.agents/skills/pr-review/SKILL.md"},
+ {"type":"dir","name":"scripts","url":"` + server.URL + `/api/v3/repos/org/repo/contents/.agents/skills/pr-review/scripts?ref=main"}
+ ]`))
+ case "/api/v3/repos/org/repo/contents/.agents/skills/pr-review/scripts":
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`[
+ {"type":"file","name":"check.sh","download_url":"` + server.URL + `/raw/org/repo/main/.agents/skills/pr-review/scripts/check.sh"}
+ ]`))
+ case "/raw/org/repo/main/.agents/skills/pr-review/SKILL.md":
+ _, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n"))
+ case "/raw/org/repo/main/.agents/skills/pr-review/scripts/check.sh":
+ _, _ = w.Write([]byte("#!/bin/sh\nexit 0\n"))
+ default:
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ }))
+ defer server.Close()
+
+ installer, err := NewSkillInstallerWithBaseURL(tmpDir, server.URL, "", "")
+ if err != nil {
+ t.Fatalf("NewSkillInstallerWithBaseURL() error = %v", err)
+ }
+
+ targetDir := filepath.Join(tmpDir, "skills", "pr-review")
+ result, err := installer.InstallFromGitHubToDir(
+ context.Background(),
+ server.URL+"/org/repo/blob/main/.agents/skills/pr-review/SKILL.md",
+ "",
+ targetDir,
+ )
+ if err != nil {
+ t.Fatalf("InstallFromGitHubToDir() error = %v", err)
+ }
+ if result.Version != "main" {
+ t.Fatalf("version = %q, want main", result.Version)
+ }
+
+ content, err := os.ReadFile(filepath.Join(targetDir, "SKILL.md"))
+ if err != nil {
+ t.Fatalf("ReadFile(SKILL.md) error = %v", err)
+ }
+ if !strings.Contains(string(content), "name: pr-review") {
+ t.Fatalf("SKILL.md content = %q, want skill metadata", string(content))
+ }
+
+ scriptPath := filepath.Join(targetDir, "scripts", "check.sh")
+ if _, err := os.Stat(scriptPath); err != nil {
+ t.Fatalf("Stat(scripts/check.sh) error = %v", err)
+ }
+}
+
func TestShouldDownload(t *testing.T) {
tests := []struct {
name string
@@ -197,6 +465,16 @@ func TestNewSkillInstaller(t *testing.T) {
t.Errorf("githubToken = %v, want 'test-token'", installer.githubToken)
}
+ if installer.githubBaseURL != "https://github.com" {
+ t.Errorf("githubBaseURL = %v, want https://github.com", installer.githubBaseURL)
+ }
+ if installer.githubAPIBaseURL != "https://api.github.com" {
+ t.Errorf("githubAPIBaseURL = %v, want https://api.github.com", installer.githubAPIBaseURL)
+ }
+ if installer.githubRawBaseURL != "https://raw.githubusercontent.com" {
+ t.Errorf("githubRawBaseURL = %v, want https://raw.githubusercontent.com", installer.githubRawBaseURL)
+ }
+
if installer.proxy != "" {
t.Errorf("proxy = %v, want empty", installer.proxy)
}
@@ -234,6 +512,24 @@ func TestNewSkillInstaller_WithProxy(t *testing.T) {
}
}
+func TestNewSkillInstaller_WithBaseURL(t *testing.T) {
+ tmpDir := t.TempDir()
+ installer, err := NewSkillInstallerWithBaseURL(tmpDir, "https://github.example.com", "test-token", "")
+ if err != nil {
+ t.Fatalf("NewSkillInstallerWithBaseURL() error = %v", err)
+ }
+
+ if installer.githubBaseURL != "https://github.example.com" {
+ t.Errorf("githubBaseURL = %v, want https://github.example.com", installer.githubBaseURL)
+ }
+ if installer.githubAPIBaseURL != "https://github.example.com/api/v3" {
+ t.Errorf("githubAPIBaseURL = %v, want https://github.example.com/api/v3", installer.githubAPIBaseURL)
+ }
+ if installer.githubRawBaseURL != "https://github.example.com/raw" {
+ t.Errorf("githubRawBaseURL = %v, want https://github.example.com/raw", installer.githubRawBaseURL)
+ }
+}
+
func TestNewSkillInstaller_InvalidProxy(t *testing.T) {
tmpDir := t.TempDir()
installer, err := NewSkillInstaller(tmpDir, "test-token", "://invalid-proxy")
diff --git a/pkg/skills/provider_factory.go b/pkg/skills/provider_factory.go
new file mode 100644
index 000000000..fe2849e1e
--- /dev/null
+++ b/pkg/skills/provider_factory.go
@@ -0,0 +1,33 @@
+package skills
+
+import (
+ "sync"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+type RegistryProviderBuilder func(name string, cfg config.SkillRegistryConfig) RegistryProvider
+
+var (
+ registryProviderBuildersMu sync.RWMutex
+ registryProviderBuilders = map[string]RegistryProviderBuilder{}
+)
+
+func RegisterRegistryProviderBuilder(name string, builder RegistryProviderBuilder) {
+ if name == "" || builder == nil {
+ return
+ }
+ registryProviderBuildersMu.Lock()
+ defer registryProviderBuildersMu.Unlock()
+ registryProviderBuilders[name] = builder
+}
+
+func buildRegistryProvider(name string, cfg config.SkillRegistryConfig) RegistryProvider {
+ registryProviderBuildersMu.RLock()
+ defer registryProviderBuildersMu.RUnlock()
+ builder := registryProviderBuilders[name]
+ if builder == nil {
+ return nil
+ }
+ return builder(name, cfg)
+}
diff --git a/pkg/skills/registry.go b/pkg/skills/registry.go
index 45ae72253..6c8e28a4e 100644
--- a/pkg/skills/registry.go
+++ b/pkg/skills/registry.go
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"log/slog"
+ "path"
+ "strings"
"sync"
"time"
)
@@ -42,11 +44,25 @@ type InstallResult struct {
Summary string
}
+// RegistryProvider creates a registry instance from configuration.
+// Different hubs can implement this to plug into the shared manager.
+type RegistryProvider interface {
+ IsEnabled() bool
+ BuildRegistry() SkillRegistry
+}
+
// SkillRegistry is the interface that all skill registries must implement.
// Each registry represents a different source of skills (e.g., clawhub.ai)
type SkillRegistry interface {
// Name returns the unique name of this registry (e.g., "clawhub").
Name() string
+ // ResolveInstallDirName returns the directory name to use under workspace/skills
+ // for a given install target. Different registries can interpret the target
+ // differently (for example, a slug vs owner/repo/path).
+ ResolveInstallDirName(target string) (string, error)
+ // SkillURL returns the web URL for a skill slug if the registry exposes one.
+ // version is optional and can be used by registries whose URLs depend on a ref.
+ SkillURL(slug, version string) string
// Search searches the registry for skills matching the query.
Search(ctx context.Context, query string, limit int) ([]SearchResult, error)
// GetSkillMeta retrieves metadata for a specific skill by slug.
@@ -57,10 +73,31 @@ type SkillRegistry interface {
DownloadAndInstall(ctx context.Context, slug, version, targetDir string) (*InstallResult, error)
}
+// InstallTargetNormalizer is implemented by registries that can canonicalize
+// user-provided install targets into a stable slug for origin metadata.
+type InstallTargetNormalizer interface {
+ NormalizeInstallTarget(target string) string
+}
+
+func NormalizeInstallTargetForRegistryInstance(registry SkillRegistry, target string) string {
+ if registry == nil || target == "" {
+ return target
+ }
+ normalizer, ok := registry.(InstallTargetNormalizer)
+ if !ok {
+ return target
+ }
+ normalized := normalizer.NormalizeInstallTarget(target)
+ if normalized == "" {
+ return target
+ }
+ return normalized
+}
+
// RegistryConfig holds configuration for all skill registries.
// This is the input to NewRegistryManagerFromConfig.
type RegistryConfig struct {
- ClawHub ClawHubConfig
+ Providers []RegistryProvider
MaxConcurrentSearches int
}
@@ -85,6 +122,29 @@ type RegistryManager struct {
mu sync.RWMutex
}
+func ValidateInstallTarget(target string) error {
+ target = strings.TrimSpace(target)
+ if target == "" {
+ return fmt.Errorf("identifier is required and must be a non-empty string")
+ }
+ if strings.Contains(target, "\\") {
+ return fmt.Errorf("identifier %q contains invalid path separators", target)
+ }
+ clean := path.Clean("/" + target)
+ if clean == "/" || strings.HasPrefix(clean, "/../") || clean == "/.." {
+ return fmt.Errorf("identifier %q contains invalid path traversal", target)
+ }
+ if strings.Contains(target, "//") {
+ return fmt.Errorf("identifier %q contains empty path segments", target)
+ }
+ for _, segment := range strings.Split(strings.Trim(target, "/"), "/") {
+ if segment == "." || segment == ".." || segment == "" {
+ return fmt.Errorf("identifier %q contains invalid path segments", target)
+ }
+ }
+ return nil
+}
+
// NewRegistryManager creates an empty RegistryManager.
func NewRegistryManager() *RegistryManager {
return &RegistryManager{
@@ -100,8 +160,15 @@ func NewRegistryManagerFromConfig(cfg RegistryConfig) *RegistryManager {
if cfg.MaxConcurrentSearches > 0 {
rm.maxConcurrent = cfg.MaxConcurrentSearches
}
- if cfg.ClawHub.Enabled {
- rm.AddRegistry(NewClawHubRegistry(cfg.ClawHub))
+ for _, provider := range cfg.Providers {
+ if provider == nil || !provider.IsEnabled() {
+ continue
+ }
+ registry := provider.BuildRegistry()
+ if registry == nil {
+ continue
+ }
+ rm.AddRegistry(registry)
}
return rm
}
diff --git a/pkg/skills/registry_test.go b/pkg/skills/registry_test.go
index a4694bd43..6ac5ffbf3 100644
--- a/pkg/skills/registry_test.go
+++ b/pkg/skills/registry_test.go
@@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/assert"
+ "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/utils"
)
@@ -24,6 +25,10 @@ type mockRegistry struct {
func (m *mockRegistry) Name() string { return m.name }
+func (m *mockRegistry) ResolveInstallDirName(target string) (string, error) { return target, nil }
+
+func (m *mockRegistry) SkillURL(slug, _ string) string { return "https://example.com/skills/" + slug }
+
func (m *mockRegistry) Search(_ context.Context, _ string, _ int) ([]SearchResult, error) {
return m.searchResults, m.searchErr
}
@@ -170,6 +175,31 @@ func TestSortByScoreDesc(t *testing.T) {
assert.Equal(t, "c", results[2].Slug)
}
+type mockProvider struct {
+ enabled bool
+ registry SkillRegistry
+}
+
+func (m mockProvider) IsEnabled() bool {
+ return m.enabled
+}
+
+func (m mockProvider) BuildRegistry() SkillRegistry {
+ return m.registry
+}
+
+func TestNewRegistryManagerFromConfigProviders(t *testing.T) {
+ mgr := NewRegistryManagerFromConfig(RegistryConfig{
+ Providers: []RegistryProvider{
+ mockProvider{enabled: true, registry: &mockRegistry{name: "alpha"}},
+ mockProvider{enabled: false, registry: &mockRegistry{name: "beta"}},
+ },
+ })
+
+ assert.NotNil(t, mgr.GetRegistry("alpha"))
+ assert.Nil(t, mgr.GetRegistry("beta"))
+}
+
func TestIsSafeSlug(t *testing.T) {
assert.NoError(t, utils.ValidateSkillIdentifier("github"))
assert.NoError(t, utils.ValidateSkillIdentifier("docker-compose"))
@@ -178,3 +208,50 @@ func TestIsSafeSlug(t *testing.T) {
assert.Error(t, utils.ValidateSkillIdentifier("path/traversal"))
assert.Error(t, utils.ValidateSkillIdentifier("path\\traversal"))
}
+
+func TestLegacyGithubBaseURLOverridesDefaultRegistryBaseURL(t *testing.T) {
+ cfg := config.DefaultConfig().Tools.Skills
+ cfg.Github.BaseURL = "https://ghe.example.com/git"
+
+ registry := LookupRegistryFromToolsConfig(cfg, "github")
+ assert.NotNil(t, registry)
+
+ ghRegistry, ok := registry.(*GitHubRegistry)
+ assert.True(t, ok)
+ assert.Equal(t, "https://ghe.example.com/git", ghRegistry.webBase)
+}
+
+func TestExplicitGithubRegistryBaseURLBeatsLegacyCompat(t *testing.T) {
+ cfg := config.DefaultConfig().Tools.Skills
+ cfg.Github.BaseURL = "https://ghe-legacy.example.com/git"
+ cfg.Registries.Set("github", config.SkillRegistryConfig{
+ Name: "github",
+ Enabled: true,
+ BaseURL: "https://ghe-explicit.example.com/scm",
+ Param: map[string]any{},
+ })
+
+ registry := LookupRegistryFromToolsConfig(cfg, "github")
+ assert.NotNil(t, registry)
+
+ ghRegistry, ok := registry.(*GitHubRegistry)
+ assert.True(t, ok)
+ assert.Equal(t, "https://ghe-explicit.example.com/scm", ghRegistry.webBase)
+}
+
+func TestNormalizeInstallTargetForRegistryCanonicalizesGitHubURLs(t *testing.T) {
+ cfg := config.DefaultConfig().Tools.Skills
+ cfg.Registries.Set("github", config.SkillRegistryConfig{
+ Name: "github",
+ Enabled: true,
+ BaseURL: "https://ghe.example.com/git",
+ Param: map[string]any{},
+ })
+
+ got := NormalizeInstallTargetForRegistry(
+ cfg,
+ "github",
+ "https://ghe.example.com/git/org/repo/tree/dev/skills/pr-review",
+ )
+ assert.Equal(t, "org/repo/skills/pr-review", got)
+}
diff --git a/pkg/tokenizer/estimator.go b/pkg/tokenizer/estimator.go
new file mode 100644
index 000000000..3265edaa8
--- /dev/null
+++ b/pkg/tokenizer/estimator.go
@@ -0,0 +1,91 @@
+package tokenizer
+
+import (
+ "encoding/json"
+ "unicode/utf8"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// EstimateMessageTokens estimates the token count for a single message,
+// including Content, ReasoningContent, ToolCalls arguments, ToolCallID
+// metadata, and Media items. Uses a heuristic of 2.5 characters per token.
+func EstimateMessageTokens(msg providers.Message) int {
+ contentChars := utf8.RuneCountInString(msg.Content)
+
+ // SystemParts are structured system blocks used for cache-aware adapters.
+ // They carry the same content as Content, but in multiple blocks.
+ // We estimate them as an alternative representation, not additive.
+ systemPartsChars := 0
+ if len(msg.SystemParts) > 0 {
+ for _, part := range msg.SystemParts {
+ systemPartsChars += utf8.RuneCountInString(part.Text)
+ }
+ // Per-part overhead for JSON structure (type, text, cache_control).
+ const perPartOverhead = 20
+ systemPartsChars += len(msg.SystemParts) * perPartOverhead
+ }
+
+ // Use the larger of the two representations to stay conservative.
+ chars := contentChars
+ if systemPartsChars > chars {
+ chars = systemPartsChars
+ }
+
+ chars += utf8.RuneCountInString(msg.ReasoningContent)
+
+ for _, tc := range msg.ToolCalls {
+ chars += len(tc.ID) + len(tc.Type)
+ if tc.Function != nil {
+ // Count function name + arguments (the wire format for most providers).
+ // tc.Name mirrors tc.Function.Name — count only once to avoid double-counting.
+ chars += len(tc.Function.Name) + len(tc.Function.Arguments)
+ } else {
+ // Fallback: some provider formats use top-level Name without Function.
+ chars += len(tc.Name)
+ }
+ }
+
+ if msg.ToolCallID != "" {
+ chars += len(msg.ToolCallID)
+ }
+
+ // Per-message overhead for role label, JSON structure, separators.
+ const messageOverhead = 12
+ chars += messageOverhead
+
+ tokens := chars * 2 / 5
+
+ // Media items (images, files) are serialized by provider adapters into
+ // multipart or image_url payloads. Add a fixed per-item token estimate
+ // directly (not through the chars heuristic) since actual cost depends
+ // on resolution and provider-specific image tokenization.
+ const mediaTokensPerItem = 256
+ tokens += len(msg.Media) * mediaTokensPerItem
+
+ return tokens
+}
+
+// EstimateToolDefsTokens estimates the total token cost of tool definitions
+// as they appear in the LLM request.
+func EstimateToolDefsTokens(defs []providers.ToolDefinition) int {
+ if len(defs) == 0 {
+ return 0
+ }
+
+ totalChars := 0
+ for _, d := range defs {
+ totalChars += len(d.Function.Name) + len(d.Function.Description)
+
+ if d.Function.Parameters != nil {
+ if paramJSON, err := json.Marshal(d.Function.Parameters); err == nil {
+ totalChars += len(paramJSON)
+ }
+ }
+
+ // Per-tool overhead: type field, JSON structure, separators.
+ totalChars += 20
+ }
+
+ return totalChars * 2 / 5
+}
diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go
index 60d9d5e5a..a9547eba9 100644
--- a/pkg/tools/cron.go
+++ b/pkg/tools/cron.go
@@ -6,6 +6,8 @@ import (
"strings"
"time"
+ "github.com/google/uuid"
+
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
@@ -18,7 +20,7 @@ type JobExecutor interface {
ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error)
// PublishResponseIfNeeded sends response to the outbound bus only when the
// agent did not already deliver content through the message tool in this round.
- PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string)
+ PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string)
}
// CronTool provides scheduling capabilities for the agent
@@ -92,7 +94,7 @@ func (t *CronTool) Parameters() map[string]any {
},
"command": map[string]any{
"type": "string",
- "description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands.",
+ "description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message.",
},
"command_confirm": map[string]any{
"type": "boolean",
@@ -114,15 +116,6 @@ func (t *CronTool) Parameters() map[string]any {
"type": "string",
"description": "Job ID (for remove/enable/disable)",
},
- "type": map[string]any{
- "type": "string",
- "enum": []string{"message", "directive"},
- "description": "Message generation strategy. 'message' (default): content is sent directly as-is. 'directive': content is treated as instructions for an AI agent to execute before delivery.",
- },
- "deliver": map[string]any{
- "type": "boolean",
- "description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: false",
- },
},
"required": []string{"action"},
}
@@ -199,18 +192,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
return ErrorResult("one of at_seconds, every_seconds, or cron_expr is required")
}
- // Read deliver parameter, default to false so scheduled tasks execute through the agent
- deliver := false
- if d, ok := args["deliver"].(bool); ok {
- deliver = d
- }
-
- // Validate type parameter (server-side whitelist, not just LLM schema hint)
- msgType, _ := args["type"].(string)
- if msgType != "" && msgType != "message" && msgType != "directive" {
- return ErrorResult(fmt.Sprintf("invalid type %q, must be 'message' or 'directive'", msgType))
- }
-
// GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel. When
// allow_command is disabled, explicit confirmation is required as an override.
// Non-command reminders remain open to all channels.
@@ -226,7 +207,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
if !t.allowCommand && !commandConfirm {
return ErrorResult("command_confirm=true is required when allow_command is disabled")
}
- deliver = false
}
// Truncate message for job name (max 30 chars)
@@ -236,7 +216,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
messagePreview,
schedule,
message,
- deliver,
channel,
chatID,
)
@@ -250,10 +229,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
job.Payload.Command = command
needsUpdate = true
}
- if msgType != "" {
- job.Payload.Type = msgType
- needsUpdate = true
- }
if needsUpdate {
t.cronService.UpdateJob(job)
}
@@ -338,8 +313,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
- Channel: channel,
- ChatID: chatID,
+ Context: bus.NewOutboundContext(channel, chatID, ""),
Content: output,
})
return "ok"
@@ -362,47 +336,18 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
- Channel: channel,
- ChatID: chatID,
+ Context: bus.NewOutboundContext(channel, chatID, ""),
Content: output,
})
return "ok"
}
- // Determine message generation strategy
- // Type="directive": treat message as instructions for AI agent to execute
- // Type="" or "message" (default): static message content
- isDirective := job.Payload.Type == "directive"
+ sessionKey := fmt.Sprintf("agent:cron-%s-%s", job.ID, uuid.New().String())
- // If deliver=true and not directive, send message directly without agent processing
- if job.Payload.Deliver && !isDirective {
- pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer pubCancel()
- t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
- Channel: channel,
- ChatID: chatID,
- Content: job.Payload.Message,
- })
- return "ok"
- }
-
- // For deliver=false OR directive mode, process through agent
- sessionKey := fmt.Sprintf("cron-%s", job.ID)
-
- // Prepare the prompt based on type
- prompt := job.Payload.Message
- if isDirective {
- // For directive type, prefix to clarify this is an instruction
- prompt = fmt.Sprintf(
- "Please execute the following directive and provide the result:\n\n%s",
- job.Payload.Message,
- )
- }
-
- // Call agent with the prepared prompt
+ // Call agent with the job message
response, err := t.executor.ProcessDirectWithChannel(
ctx,
- prompt,
+ job.Payload.Message,
sessionKey,
channel,
chatID,
@@ -412,7 +357,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
}
if response != "" {
- t.executor.PublishResponseIfNeeded(ctx, channel, chatID, response)
+ t.executor.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, response)
}
return "ok"
}
diff --git a/pkg/tools/cron_test.go b/pkg/tools/cron_test.go
index 186c6a75e..0e527c98a 100644
--- a/pkg/tools/cron_test.go
+++ b/pkg/tools/cron_test.go
@@ -24,6 +24,7 @@ type stubJobExecutor struct {
publishedResp string
publishedChan string
publishedChatID string
+ publishedKey string
}
func (s *stubJobExecutor) ProcessDirectWithChannel(
@@ -39,7 +40,7 @@ func (s *stubJobExecutor) ProcessDirectWithChannel(
func (s *stubJobExecutor) PublishResponseIfNeeded(
_ context.Context,
- channel, chatID, response string,
+ channel, chatID, sessionKey, response string,
) {
if s.alreadySent {
return
@@ -47,6 +48,7 @@ func (s *stubJobExecutor) PublishResponseIfNeeded(
s.publishedResp = response
s.publishedChan = channel
s.publishedChatID = chatID
+ s.publishedKey = sessionKey
}
func newTestCronToolWithExecutorAndConfig(t *testing.T, executor JobExecutor, cfg *config.Config) *CronTool {
@@ -229,28 +231,6 @@ func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) {
}
}
-func TestCronTool_NonCommandJobDefaultsDeliverToFalse(t *testing.T) {
- tool := newTestCronTool(t)
- ctx := WithToolContext(context.Background(), "telegram", "chat-1")
- result := tool.Execute(ctx, map[string]any{
- "action": "add",
- "message": "send me a poem",
- "at_seconds": float64(600),
- })
-
- if result.IsError {
- t.Fatalf("expected non-command reminder to succeed, got: %s", result.ForLLM)
- }
-
- jobs := tool.cronService.ListJobs(false)
- if len(jobs) != 1 {
- t.Fatalf("expected 1 job, got %d", len(jobs))
- }
- if jobs[0].Payload.Deliver {
- t.Fatal("expected deliver=false by default for non-command jobs")
- }
-}
-
func TestCronTool_ExecuteJobPublishesErrorWhenExecDisabled(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Exec.Enabled = false
@@ -293,8 +273,8 @@ func TestCronTool_ExecuteJobPublishesAgentResponse(t *testing.T) {
t.Fatalf("ExecuteJob() = %q, want ok", got)
}
- if executor.lastKey != "cron-job-1" {
- t.Fatalf("sessionKey = %q, want cron-job-1", executor.lastKey)
+ if !strings.HasPrefix(executor.lastKey, "agent:cron-job-1-") {
+ t.Fatalf("sessionKey = %q, want agent:cron-job-1-{uuid}", executor.lastKey)
}
if executor.lastChan != "telegram" || executor.lastChatID != "chat-1" {
t.Fatalf("executor target = %s/%s, want telegram/chat-1", executor.lastChan, executor.lastChatID)
@@ -305,6 +285,9 @@ func TestCronTool_ExecuteJobPublishesAgentResponse(t *testing.T) {
if executor.publishedResp != "generated reply" {
t.Fatalf("published response = %q, want generated reply", executor.publishedResp)
}
+ if executor.publishedKey != executor.lastKey {
+ t.Fatalf("published sessionKey = %q, want %q", executor.publishedKey, executor.lastKey)
+ }
if executor.publishedChan != "telegram" || executor.publishedChatID != "chat-1" {
t.Fatalf("published target = %s/%s, want telegram/chat-1", executor.publishedChan, executor.publishedChatID)
}
@@ -346,93 +329,6 @@ func TestCronTool_ExecuteJobSkipsWhenMessageToolAlreadySent(t *testing.T) {
}
}
-func TestCronTool_ExecuteJobDirectiveAddsPromptPrefix(t *testing.T) {
- executor := &stubJobExecutor{response: "directive result"}
- tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig())
-
- originalMsg := "check the weather and summarize"
- job := &cron.CronJob{ID: "job-dir-1"}
- job.Payload.Channel = "telegram"
- job.Payload.To = "chat-1"
- job.Payload.Message = originalMsg
- job.Payload.Type = "directive"
-
- if got := tool.ExecuteJob(context.Background(), job); got != "ok" {
- t.Fatalf("ExecuteJob() = %q, want ok", got)
- }
-
- wantPrompt := "Please execute the following directive and provide the result:\n\n" + originalMsg
- if executor.lastPrompt != wantPrompt {
- t.Fatalf("prompt = %q, want exact %q", executor.lastPrompt, wantPrompt)
- }
- if executor.publishedResp != "directive result" {
- t.Fatalf("published response = %q, want %q", executor.publishedResp, "directive result")
- }
-}
-
-func TestCronTool_ExecuteJobDirectiveWithDeliverRoutesToAgent(t *testing.T) {
- executor := &stubJobExecutor{response: "agent processed"}
- tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig())
-
- job := &cron.CronJob{ID: "job-dir-deliver"}
- job.Payload.Channel = "telegram"
- job.Payload.To = "chat-1"
- job.Payload.Message = "generate daily report"
- job.Payload.Type = "directive"
- job.Payload.Deliver = true
-
- if got := tool.ExecuteJob(context.Background(), job); got != "ok" {
- t.Fatalf("ExecuteJob() = %q, want ok", got)
- }
-
- if executor.lastPrompt == "" {
- t.Fatal("expected agent to be called for directive+deliver, but ProcessDirectWithChannel was not invoked")
- }
- if executor.publishedResp != "agent processed" {
- t.Fatalf("published response = %q, want %q", executor.publishedResp, "agent processed")
- }
-
- // Verify no direct publish happened on the bus (agent path, not direct path)
- ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
- defer cancel()
- select {
- case msg := <-tool.msgBus.OutboundChan():
- t.Fatalf("unexpected direct bus message: %+v", msg)
- case <-ctx.Done():
- // expected: no direct bus message
- }
-}
-
-func TestCronTool_ExecuteJobDeliverMessageDirectlyToBus(t *testing.T) {
- executor := &stubJobExecutor{response: "should not be called"}
- tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig())
-
- job := &cron.CronJob{ID: "job-deliver"}
- job.Payload.Channel = "telegram"
- job.Payload.To = "chat-1"
- job.Payload.Message = "hello world"
- job.Payload.Deliver = true
-
- if got := tool.ExecuteJob(context.Background(), job); got != "ok" {
- t.Fatalf("ExecuteJob() = %q, want ok", got)
- }
-
- if executor.lastPrompt != "" {
- t.Fatal("expected agent NOT to be invoked for deliver=true message type")
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
- defer cancel()
- select {
- case msg := <-tool.msgBus.OutboundChan():
- if msg.Content != "hello world" {
- t.Fatalf("bus content = %q, want %q", msg.Content, "hello world")
- }
- case <-ctx.Done():
- t.Fatal("timeout waiting for direct bus message")
- }
-}
-
func TestCronTool_ExecuteJobReturnsErrorWithoutPublish(t *testing.T) {
executor := &stubJobExecutor{
response: "this response must not be published",
@@ -454,43 +350,3 @@ func TestCronTool_ExecuteJobReturnsErrorWithoutPublish(t *testing.T) {
t.Fatalf("unexpected publish on error path: %q", executor.publishedResp)
}
}
-
-func TestCronTool_AddJobRejectsInvalidType(t *testing.T) {
- tool := newTestCronTool(t)
- ctx := WithToolContext(context.Background(), "cli", "direct")
- result := tool.Execute(ctx, map[string]any{
- "action": "add",
- "message": "test",
- "at_seconds": float64(60),
- "type": "invalid_type",
- })
-
- if !result.IsError {
- t.Fatal("expected error for invalid type parameter")
- }
- if !strings.Contains(result.ForLLM, "invalid type") {
- t.Errorf("expected 'invalid type' error, got: %s", result.ForLLM)
- }
-}
-
-func TestCronTool_AddJobAcceptsValidTypes(t *testing.T) {
- for _, msgType := range []string{"", "message", "directive"} {
- t.Run("type="+msgType, func(t *testing.T) {
- tool := newTestCronTool(t)
- ctx := WithToolContext(context.Background(), "cli", "direct")
- args := map[string]any{
- "action": "add",
- "message": "test",
- "at_seconds": float64(60),
- }
- if msgType != "" {
- args["type"] = msgType
- }
-
- result := tool.Execute(ctx, args)
- if result.IsError {
- t.Fatalf("expected valid type %q to succeed, got: %s", msgType, result.ForLLM)
- }
- })
- }
-}
diff --git a/pkg/tools/delegate.go b/pkg/tools/delegate.go
new file mode 100644
index 000000000..dcde27718
--- /dev/null
+++ b/pkg/tools/delegate.go
@@ -0,0 +1,104 @@
+package tools
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/routing"
+)
+
+// DelegateTool delegates a task to a specific named agent and waits for
+// the result. Unlike spawn (async, fire-and-forget) or subagent (sync but
+// generic), delegate targets a named agent and runs the task using that
+// agent's own workspace, model, and tools.
+type DelegateTool struct {
+ spawner SubTurnSpawner
+ allowlistCheck func(targetAgentID string) bool
+ selfAgentID string
+}
+
+func NewDelegateTool() *DelegateTool {
+ return &DelegateTool{}
+}
+
+func (t *DelegateTool) SetSpawner(spawner SubTurnSpawner) {
+ t.spawner = spawner
+}
+
+func (t *DelegateTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
+ t.allowlistCheck = check
+}
+
+func (t *DelegateTool) SetSelfAgentID(id string) {
+ t.selfAgentID = id
+}
+
+func (t *DelegateTool) Name() string {
+ return "delegate"
+}
+
+func (t *DelegateTool) Description() string {
+ return "Delegate a task to another agent and wait for the result. " +
+ "Use this when another agent is better suited to handle a specific task " +
+ "based on their capabilities. The target agent runs with its own workspace, " +
+ "model, and tools."
+}
+
+func (t *DelegateTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "agent_id": map[string]any{
+ "type": "string",
+ "description": "The ID of the target agent to delegate the task to",
+ },
+ "task": map[string]any{
+ "type": "string",
+ "description": "Clear description of the task to delegate",
+ },
+ },
+ "required": []string{"agent_id", "task"},
+ }
+}
+
+func (t *DelegateTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ rawAgentID, _ := args["agent_id"].(string)
+ if strings.TrimSpace(rawAgentID) == "" {
+ return ErrorResult("agent_id is required and must be a non-empty string")
+ }
+ agentID := routing.NormalizeAgentID(rawAgentID)
+
+ task, _ := args["task"].(string)
+ if strings.TrimSpace(task) == "" {
+ return ErrorResult("task is required and must be a non-empty string")
+ }
+
+ if t.selfAgentID != "" && agentID == t.selfAgentID {
+ return ErrorResult("cannot delegate to self")
+ }
+
+ if t.allowlistCheck != nil && !t.allowlistCheck(agentID) {
+ return ErrorResult(fmt.Sprintf("not allowed to delegate to agent %q", agentID))
+ }
+
+ if t.spawner == nil {
+ return ErrorResult("delegate tool not configured")
+ }
+
+ result, err := t.spawner.SpawnSubTurn(ctx, SubTurnConfig{
+ TargetAgentID: agentID,
+ SystemPrompt: task,
+ Async: false,
+ })
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("delegation to agent %q failed: %v", agentID, err)).WithError(err)
+ }
+ if result == nil {
+ return ErrorResult(fmt.Sprintf("delegation to agent %q returned no result", agentID))
+ }
+
+ result.ForLLM = fmt.Sprintf("[Response from agent %q]\n%s", agentID, result.ForLLM)
+
+ return result
+}
diff --git a/pkg/tools/delegate_test.go b/pkg/tools/delegate_test.go
new file mode 100644
index 000000000..729c524a7
--- /dev/null
+++ b/pkg/tools/delegate_test.go
@@ -0,0 +1,300 @@
+package tools
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+)
+
+// delegateMockSpawner records the config and returns a canned result.
+type delegateMockSpawner struct {
+ lastCfg SubTurnConfig
+ result *ToolResult
+ err error
+}
+
+func (m *delegateMockSpawner) SpawnSubTurn(_ context.Context, cfg SubTurnConfig) (*ToolResult, error) {
+ m.lastCfg = cfg
+ if m.err != nil {
+ return nil, m.err
+ }
+ if m.result != nil {
+ return m.result, nil
+ }
+ return &ToolResult{
+ ForLLM: "completed: " + cfg.SystemPrompt,
+ ForUser: "completed",
+ }, nil
+}
+
+func TestDelegateTool_Name(t *testing.T) {
+ tool := NewDelegateTool()
+ if tool.Name() != "delegate" {
+ t.Errorf("Name() = %q, want %q", tool.Name(), "delegate")
+ }
+}
+
+func TestDelegateTool_Parameters(t *testing.T) {
+ tool := NewDelegateTool()
+ params := tool.Parameters()
+
+ props, ok := params["properties"].(map[string]any)
+ if !ok {
+ t.Fatal("properties should be a map")
+ }
+ _, hasAgentID := props["agent_id"]
+ if !hasAgentID {
+ t.Error("agent_id parameter should exist")
+ }
+ _, hasTask := props["task"]
+ if !hasTask {
+ t.Error("task parameter should exist")
+ }
+
+ required, ok := params["required"].([]string)
+ if !ok {
+ t.Fatal("required should be a string array")
+ }
+ if len(required) != 2 {
+ t.Fatalf("required should have 2 entries, got %d", len(required))
+ }
+}
+
+func TestDelegateTool_Execute_Success(t *testing.T) {
+ spawner := &delegateMockSpawner{}
+ tool := NewDelegateTool()
+ tool.SetSpawner(spawner)
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": "researcher",
+ "task": "summarize the logs",
+ })
+
+ if result.IsError {
+ t.Fatalf("expected success, got error: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, `[Response from agent "researcher"]`) {
+ t.Errorf("result should contain attribution, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "summarize the logs") {
+ t.Errorf("result should contain task output, got: %s", result.ForLLM)
+ }
+
+ // Verify spawner received correct config
+ if spawner.lastCfg.TargetAgentID != "researcher" {
+ t.Errorf("TargetAgentID = %q, want %q", spawner.lastCfg.TargetAgentID, "researcher")
+ }
+ if spawner.lastCfg.Async {
+ t.Error("delegate should be synchronous (Async=false)")
+ }
+ if spawner.lastCfg.SystemPrompt != "summarize the logs" {
+ t.Errorf("SystemPrompt = %q, want %q", spawner.lastCfg.SystemPrompt, "summarize the logs")
+ }
+}
+
+func TestDelegateTool_Execute_EmptyAgentID(t *testing.T) {
+ tests := []struct {
+ name string
+ args map[string]any
+ }{
+ {"missing", map[string]any{"task": "test"}},
+ {"empty string", map[string]any{"agent_id": "", "task": "test"}},
+ {"whitespace only", map[string]any{"agent_id": " ", "task": "test"}},
+ {"wrong type", map[string]any{"agent_id": 123, "task": "test"}},
+ }
+
+ tool := NewDelegateTool()
+ tool.SetSpawner(&delegateMockSpawner{})
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tool.Execute(context.Background(), tt.args)
+ if !result.IsError {
+ t.Error("expected error for invalid agent_id")
+ }
+ if !strings.Contains(result.ForLLM, "agent_id is required") {
+ t.Errorf("error should mention agent_id, got: %s", result.ForLLM)
+ }
+ })
+ }
+}
+
+func TestDelegateTool_Execute_EmptyTask(t *testing.T) {
+ tests := []struct {
+ name string
+ args map[string]any
+ }{
+ {"missing", map[string]any{"agent_id": "a"}},
+ {"empty string", map[string]any{"agent_id": "a", "task": ""}},
+ {"whitespace only", map[string]any{"agent_id": "a", "task": "\t\n"}},
+ }
+
+ tool := NewDelegateTool()
+ tool.SetSpawner(&delegateMockSpawner{})
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tool.Execute(context.Background(), tt.args)
+ if !result.IsError {
+ t.Error("expected error for invalid task")
+ }
+ if !strings.Contains(result.ForLLM, "task is required") {
+ t.Errorf("error should mention task, got: %s", result.ForLLM)
+ }
+ })
+ }
+}
+
+func TestDelegateTool_Execute_PermissionDenied(t *testing.T) {
+ tool := NewDelegateTool()
+ tool.SetSpawner(&delegateMockSpawner{})
+ tool.SetAllowlistChecker(func(targetAgentID string) bool {
+ return targetAgentID == "allowed-agent"
+ })
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": "forbidden-agent",
+ "task": "test",
+ })
+
+ if !result.IsError {
+ t.Error("expected error for denied agent")
+ }
+ if !strings.Contains(result.ForLLM, "not allowed to delegate") {
+ t.Errorf("error should mention permission, got: %s", result.ForLLM)
+ }
+}
+
+func TestDelegateTool_Execute_PermissionAllowed(t *testing.T) {
+ tool := NewDelegateTool()
+ tool.SetSpawner(&delegateMockSpawner{})
+ tool.SetAllowlistChecker(func(targetAgentID string) bool {
+ return targetAgentID == "allowed-agent"
+ })
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": "allowed-agent",
+ "task": "test",
+ })
+
+ if result.IsError {
+ t.Errorf("expected success for allowed agent, got error: %s", result.ForLLM)
+ }
+}
+
+func TestDelegateTool_Execute_NoSpawner(t *testing.T) {
+ tool := NewDelegateTool()
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": "a",
+ "task": "test",
+ })
+
+ if !result.IsError {
+ t.Error("expected error when spawner is nil")
+ }
+ if !strings.Contains(result.ForLLM, "not configured") {
+ t.Errorf("error should mention not configured, got: %s", result.ForLLM)
+ }
+}
+
+func TestDelegateTool_Execute_SpawnerError(t *testing.T) {
+ spawner := &delegateMockSpawner{
+ err: fmt.Errorf("context deadline exceeded"),
+ }
+ tool := NewDelegateTool()
+ tool.SetSpawner(spawner)
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": "researcher",
+ "task": "test",
+ })
+
+ if !result.IsError {
+ t.Error("expected error when spawner fails")
+ }
+ if !strings.Contains(result.ForLLM, "delegation to agent") {
+ t.Errorf("error should mention delegation failure, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "context deadline exceeded") {
+ t.Errorf("error should propagate cause, got: %s", result.ForLLM)
+ }
+}
+
+func TestDelegateTool_Execute_NoAllowlistCheck(t *testing.T) {
+ // When no allowlist checker is set, all agents are allowed
+ tool := NewDelegateTool()
+ tool.SetSpawner(&delegateMockSpawner{})
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": "any-agent",
+ "task": "test",
+ })
+
+ if result.IsError {
+ t.Errorf("expected success without allowlist, got error: %s", result.ForLLM)
+ }
+}
+
+func TestDelegateTool_Execute_NilResult(t *testing.T) {
+ tool := NewDelegateTool()
+ tool.SetSpawner(&nilResultSpawner{})
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": "researcher",
+ "task": "test",
+ })
+
+ if !result.IsError {
+ t.Error("expected error for nil result")
+ }
+ if !strings.Contains(result.ForLLM, "returned no result") {
+ t.Errorf("error should mention no result, got: %s", result.ForLLM)
+ }
+}
+
+func TestDelegateTool_Execute_SelfDelegation(t *testing.T) {
+ tool := NewDelegateTool()
+ tool.SetSpawner(&delegateMockSpawner{})
+ tool.SetSelfAgentID("alpha")
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": "alpha",
+ "task": "test",
+ })
+
+ if !result.IsError {
+ t.Error("expected error for self-delegation")
+ }
+ if !strings.Contains(result.ForLLM, "cannot delegate to self") {
+ t.Errorf("error should mention self-delegation, got: %s", result.ForLLM)
+ }
+}
+
+func TestDelegateTool_Execute_SelfDelegation_Normalized(t *testing.T) {
+ tool := NewDelegateTool()
+ tool.SetSpawner(&delegateMockSpawner{})
+ tool.SetSelfAgentID("alpha") // stored normalized
+
+ // Case-insensitive and whitespace variants should still be caught
+ variants := []string{"ALPHA", " Alpha ", " alpha "}
+ for _, v := range variants {
+ t.Run(v, func(t *testing.T) {
+ result := tool.Execute(context.Background(), map[string]any{
+ "agent_id": v,
+ "task": "test",
+ })
+ if !result.IsError {
+ t.Errorf("agent_id=%q should be caught as self-delegation", v)
+ }
+ })
+ }
+}
+
+// nilResultSpawner always returns (nil, nil).
+type nilResultSpawner struct{}
+
+func (m *nilResultSpawner) SpawnSubTurn(_ context.Context, _ SubTurnConfig) (*ToolResult, error) {
+ return nil, nil
+}
diff --git a/pkg/tools/facade_compat_test.go b/pkg/tools/facade_compat_test.go
new file mode 100644
index 000000000..378462512
--- /dev/null
+++ b/pkg/tools/facade_compat_test.go
@@ -0,0 +1,18 @@
+package tools
+
+import "testing"
+
+func TestFacadeConstructorsRemainAvailable(t *testing.T) {
+ if NewI2CTool() == nil {
+ t.Fatal("NewI2CTool should return a tool")
+ }
+ if NewSPITool() == nil {
+ t.Fatal("NewSPITool should return a tool")
+ }
+ if NewSerialTool() == nil {
+ t.Fatal("NewSerialTool should return a tool")
+ }
+ if NewMessageTool() == nil {
+ t.Fatal("NewMessageTool should return a tool")
+ }
+}
diff --git a/pkg/tools/edit.go b/pkg/tools/fs/edit.go
similarity index 86%
rename from pkg/tools/edit.go
rename to pkg/tools/fs/edit.go
index d5bebf4a2..827ea50c8 100644
--- a/pkg/tools/edit.go
+++ b/pkg/tools/fs/edit.go
@@ -1,4 +1,4 @@
-package tools
+package fstools
import (
"context"
@@ -29,7 +29,7 @@ func (t *EditFileTool) Name() string {
}
func (t *EditFileTool) Description() string {
- return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file."
+ return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n."
}
func (t *EditFileTool) Parameters() map[string]any {
@@ -42,11 +42,11 @@ func (t *EditFileTool) Parameters() map[string]any {
},
"old_text": map[string]any{
"type": "string",
- "description": "The exact text to find and replace",
+ "description": "The exact text to find and replace. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
},
"new_text": map[string]any{
"type": "string",
- "description": "The text to replace with",
+ "description": "The text to replace with. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
},
},
"required": []string{"path", "old_text", "new_text"},
@@ -92,7 +92,7 @@ func (t *AppendFileTool) Name() string {
}
func (t *AppendFileTool) Description() string {
- return "Append content to the end of a file"
+ return "Append content to the end of a file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n."
}
func (t *AppendFileTool) Parameters() map[string]any {
@@ -105,7 +105,7 @@ func (t *AppendFileTool) Parameters() map[string]any {
},
"content": map[string]any{
"type": "string",
- "description": "The content to append",
+ "description": "The content to append. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
},
},
"required": []string{"path", "content"},
diff --git a/pkg/tools/edit_test.go b/pkg/tools/fs/edit_test.go
similarity index 99%
rename from pkg/tools/edit_test.go
rename to pkg/tools/fs/edit_test.go
index 83a7e778c..4c25322ef 100644
--- a/pkg/tools/edit_test.go
+++ b/pkg/tools/fs/edit_test.go
@@ -1,4 +1,4 @@
-package tools
+package fstools
import (
"context"
diff --git a/pkg/tools/filesystem.go b/pkg/tools/fs/filesystem.go
similarity index 69%
rename from pkg/tools/filesystem.go
rename to pkg/tools/fs/filesystem.go
index 39d45013d..262d88d99 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/fs/filesystem.go
@@ -1,18 +1,22 @@
-package tools
+package fstools
import (
+ "bufio"
+ "bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"math"
+ "net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
+ "unicode/utf8"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -20,7 +24,23 @@ import (
const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow
-func validatePathWithAllowPaths(path, workspace string, restrict bool, patterns []*regexp.Regexp) (string, error) {
+func ValidatePathWithAllowPaths(
+ path, workspace string,
+ restrict bool,
+ patterns []*regexp.Regexp,
+) (string, error) {
+ return validatePathWithAllowPaths(path, workspace, restrict, patterns)
+}
+
+func IsAllowedPath(path string, patterns []*regexp.Regexp) bool {
+ return isAllowedPath(path, patterns)
+}
+
+func validatePathWithAllowPaths(
+ path, workspace string,
+ restrict bool,
+ patterns []*regexp.Regexp,
+) (string, error) {
if workspace == "" {
return path, fmt.Errorf("workspace is not defined")
}
@@ -253,6 +273,11 @@ type ReadFileTool struct {
maxSize int64
}
+type ReadFileLinesTool struct {
+ fs fileSystem
+ maxSize int64
+}
+
func NewReadFileTool(
workspace string,
restrict bool,
@@ -275,14 +300,53 @@ func NewReadFileTool(
}
}
+func NewReadFileBytesTool(
+ workspace string,
+ restrict bool,
+ maxReadFileSize int,
+ allowPaths ...[]*regexp.Regexp,
+) *ReadFileTool {
+ return NewReadFileTool(workspace, restrict, maxReadFileSize, allowPaths...)
+}
+
+func NewReadFileLinesTool(
+ workspace string,
+ restrict bool,
+ maxReadFileSize int,
+ allowPaths ...[]*regexp.Regexp,
+) *ReadFileLinesTool {
+ var patterns []*regexp.Regexp
+ if len(allowPaths) > 0 {
+ patterns = allowPaths[0]
+ }
+
+ maxSize := int64(maxReadFileSize)
+ if maxSize <= 0 {
+ maxSize = MaxReadFileSize
+ }
+
+ return &ReadFileLinesTool{
+ fs: buildFs(workspace, restrict, patterns),
+ maxSize: maxSize,
+ }
+}
+
func (t *ReadFileTool) Name() string {
return "read_file"
}
+func (t *ReadFileLinesTool) Name() string {
+ return "read_file"
+}
+
func (t *ReadFileTool) Description() string {
return "Read the contents of a file. Supports pagination via `offset` and `length`."
}
+func (t *ReadFileLinesTool) Description() string {
+ return "Read a UTF-8 text file from the filesystem. Output always includes line numbers in the format `LINE_NUMBER|LINE_CONTENT` (1-indexed). Supports partial reads via `start_line` and `max_lines` for large text files."
+}
+
func (t *ReadFileTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
@@ -306,6 +370,28 @@ func (t *ReadFileTool) Parameters() map[string]any {
}
}
+func (t *ReadFileLinesTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "path": map[string]any{
+ "type": "string",
+ "description": "Path to the file to read.",
+ },
+ "start_line": map[string]any{
+ "type": "integer",
+ "description": "Line number to start reading from (1-indexed, inclusive).",
+ "default": 1,
+ },
+ "max_lines": map[string]any{
+ "type": "integer",
+ "description": "Maximum number of lines to read.",
+ },
+ },
+ "required": []string{"path"},
+ }
+}
+
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
@@ -447,6 +533,302 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return NewToolResult(header + "\n\n" + string(data))
}
+func (t *ReadFileLinesTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ path, ok := args["path"].(string)
+ if !ok {
+ return ErrorResult("path is required")
+ }
+
+ startLine, err := getInt64Arg(args, "start_line", 1)
+ if err != nil {
+ return ErrorResult(err.Error())
+ }
+ if startLine < 1 {
+ return ErrorResult("start_line must be >= 1")
+ }
+ if _, exists := args["offset"]; exists {
+ return ErrorResult("offset is not supported in line mode; use start_line")
+ }
+ if _, exists := args["length"]; exists {
+ return ErrorResult("length is not supported in line mode; use max_lines")
+ }
+ if _, exists := args["limit"]; exists {
+ return ErrorResult("limit is not supported in line mode; use max_lines")
+ }
+
+ limit := int64(-1)
+ if raw, exists := args["max_lines"]; exists && raw != nil {
+ limit, err = getInt64Arg(args, "max_lines", -1)
+ if err != nil {
+ return ErrorResult(err.Error())
+ }
+ if limit <= 0 {
+ return ErrorResult("max_lines, if provided, must be > 0")
+ }
+ }
+
+ file, err := t.fs.Open(path)
+ if err != nil {
+ return ErrorResult(err.Error())
+ }
+ defer file.Close()
+
+ if info, statErr := file.Stat(); statErr == nil && info.IsDir() {
+ return ErrorResult(fmt.Sprintf("failed to open file: path is a directory: %s", path))
+ }
+
+ sample := make([]byte, 512)
+ sampleN, readErr := file.Read(sample)
+ if readErr != nil && readErr != io.EOF {
+ return ErrorResult(fmt.Sprintf("failed to read file: %v", readErr))
+ }
+ sample = sample[:sampleN]
+ if isBinaryReadFileData(sample) {
+ return ErrorResult("file appears to be binary; switch read_file mode to 'bytes' for byte-based inspection")
+ }
+
+ reader := bufio.NewReaderSize(io.MultiReader(bytes.NewReader(sample), file), 32*1024)
+
+ var content strings.Builder
+ lineIndex := int64(1)
+ var linesRead int64
+ var fileBytesRead int64
+ var outputBytesRead int64
+ var reachedEOF bool
+ var byteBudgetTruncated bool
+ var lineTruncated bool
+
+ for lineIndex < startLine {
+ hasLine, consumeErr := consumeNextLine(reader)
+ if consumeErr != nil {
+ return ErrorResult(fmt.Sprintf("failed to read file content: %v", consumeErr))
+ }
+ if !hasLine {
+ reachedEOF = true
+ break
+ }
+ lineIndex++
+ }
+
+ for !reachedEOF && (limit < 0 || linesRead < limit) {
+ prefix := formatReadFileLinePrefix(lineIndex)
+ remaining := t.maxSize - outputBytesRead - int64(len(prefix))
+ if remaining <= 0 {
+ byteBudgetTruncated = true
+ break
+ }
+
+ line, complete, hasLine, readLineErr := readNextLinePrefix(reader, remaining)
+ if readLineErr != nil {
+ return ErrorResult(fmt.Sprintf("failed to read file content: %v", readLineErr))
+ }
+ if !hasLine {
+ reachedEOF = true
+ break
+ }
+
+ content.WriteString(prefix)
+ content.Write(line)
+ fileBytesRead += int64(len(line))
+ outputBytesRead += int64(len(prefix) + len(line))
+ linesRead++
+ lineIndex++
+
+ if !complete {
+ byteBudgetTruncated = true
+ lineTruncated = true
+ break
+ }
+ }
+
+ if !reachedEOF && !lineTruncated {
+ hasMoreContent, peekErr := readerHasMoreContent(reader)
+ if peekErr != nil {
+ return ErrorResult(fmt.Sprintf("failed to inspect remaining file content: %v", peekErr))
+ }
+ if !hasMoreContent {
+ reachedEOF = true
+ byteBudgetTruncated = false
+ }
+ }
+
+ if linesRead == 0 && content.Len() == 0 {
+ return NewToolResult(fmt.Sprintf("[END OF FILE - no content at or after start_line=%d]", startLine))
+ }
+
+ start := startLine
+ endLine := startLine + linesRead - 1
+ displayPath := filepath.Base(path)
+ header := fmt.Sprintf(
+ "[file: %s | read: lines %d-%d (1-indexed) | file_bytes: %d | output_bytes: %d]",
+ displayPath, start, endLine, fileBytesRead, outputBytesRead,
+ )
+
+ switch {
+ case lineTruncated:
+ header += fmt.Sprintf(
+ "\n[TRUNCATED - line %d exceeded the %d byte read budget and was cut mid-line.]",
+ endLine,
+ t.maxSize,
+ )
+ case byteBudgetTruncated:
+ if limit > 0 {
+ header += fmt.Sprintf(
+ "\n[TRUNCATED - byte budget reached. Call read_file again with start_line=%d and max_lines=%d to continue at the next line.]",
+ startLine+linesRead,
+ limit,
+ )
+ } else {
+ header += fmt.Sprintf(
+ "\n[TRUNCATED - byte budget reached. Call read_file again with start_line=%d to continue at the next line.]",
+ startLine+linesRead,
+ )
+ }
+ case !reachedEOF && limit > 0 && linesRead >= limit:
+ header += fmt.Sprintf(
+ "\n[PARTIAL - more content remains. Call read_file again with start_line=%d and max_lines=%d to continue.]",
+ startLine+linesRead,
+ limit,
+ )
+ default:
+ header += "\n[END OF FILE - no further content.]"
+ }
+
+ logger.DebugCF("tool", "ReadFileTool execution completed successfully",
+ map[string]any{
+ "path": path,
+ "lines_read": linesRead,
+ "file_bytes_read": fileBytesRead,
+ "output_bytes_read": outputBytesRead,
+ "truncated": byteBudgetTruncated,
+ "tool": t.Name(),
+ })
+
+ return NewToolResult(header + "\n\n" + content.String())
+}
+
+func formatReadFileLinePrefix(lineNumber int64) string {
+ return strconv.FormatInt(lineNumber, 10) + "|"
+}
+
+func isBinaryReadFileData(data []byte) bool {
+ if len(data) == 0 {
+ return false
+ }
+
+ sample := data
+ if len(sample) > 512 {
+ sample = sample[:512]
+ }
+
+ if bytes.IndexByte(sample, 0) >= 0 {
+ return true
+ }
+
+ contentType := http.DetectContentType(sample)
+ if strings.HasPrefix(contentType, "text/") {
+ return false
+ }
+ if strings.HasSuffix(contentType, "/json") ||
+ strings.HasSuffix(contentType, "+json") ||
+ strings.HasSuffix(contentType, "/xml") ||
+ strings.HasSuffix(contentType, "+xml") ||
+ strings.Contains(contentType, "javascript") {
+ return false
+ }
+
+ if !utf8.Valid(sample) {
+ return true
+ }
+
+ controlChars := 0
+ for _, b := range sample {
+ if b < 0x20 && b != '\n' && b != '\r' && b != '\t' && b != '\f' && b != '\b' {
+ controlChars++
+ }
+ }
+
+ return float64(controlChars)/float64(len(sample)) > 0.1
+}
+
+func consumeNextLine(reader *bufio.Reader) (bool, error) {
+ sawData := false
+
+ for {
+ fragment, err := reader.ReadSlice('\n')
+ if len(fragment) > 0 {
+ sawData = true
+ }
+
+ switch {
+ case err == nil:
+ return true, nil
+ case errors.Is(err, bufio.ErrBufferFull):
+ continue
+ case errors.Is(err, io.EOF):
+ return sawData, nil
+ default:
+ return false, err
+ }
+ }
+}
+
+func readNextLinePrefix(reader *bufio.Reader, maxBytes int64) ([]byte, bool, bool, error) {
+ if maxBytes <= 0 {
+ return nil, false, false, nil
+ }
+
+ var out bytes.Buffer
+ sawData := false
+ complete := true
+
+ for {
+ fragment, err := reader.ReadSlice('\n')
+ if len(fragment) > 0 {
+ sawData = true
+ if remaining := maxBytes - int64(out.Len()); remaining > 0 {
+ take := len(fragment)
+ if int64(take) > remaining {
+ take = int(remaining)
+ complete = false
+ }
+ out.Write(fragment[:take])
+ } else {
+ complete = false
+ }
+ }
+
+ switch {
+ case err == nil:
+ return out.Bytes(), complete, sawData, nil
+ case errors.Is(err, bufio.ErrBufferFull):
+ if !complete {
+ return out.Bytes(), false, true, nil
+ }
+ continue
+ case errors.Is(err, io.EOF):
+ if !sawData {
+ return nil, true, false, nil
+ }
+ return out.Bytes(), complete, true, nil
+ default:
+ return nil, false, false, err
+ }
+ }
+}
+
+func readerHasMoreContent(reader *bufio.Reader) (bool, error) {
+ _, err := reader.Peek(1)
+ switch {
+ case err == nil:
+ return true, nil
+ case errors.Is(err, io.EOF):
+ return false, nil
+ default:
+ return false, err
+ }
+}
+
// getInt64Arg extracts an integer argument from the args map, returning the
// provided default if the key is absent.
func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) {
@@ -483,7 +865,11 @@ type WriteFileTool struct {
fs fileSystem
}
-func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool {
+func NewWriteFileTool(
+ workspace string,
+ restrict bool,
+ allowPaths ...[]*regexp.Regexp,
+) *WriteFileTool {
var patterns []*regexp.Regexp
if len(allowPaths) > 0 {
patterns = allowPaths[0]
@@ -496,7 +882,7 @@ func (t *WriteFileTool) Name() string {
}
func (t *WriteFileTool) Description() string {
- return "Write content to a file. If the file already exists, you must set overwrite=true to replace it."
+ return "Write content to a file. Content is written byte-for-byte after argument decoding. Standard JSON escaping applies: \\n for newline and \\\\n for a literal backslash-n sequence. If the file already exists, you must set overwrite=true to replace it."
}
func (t *WriteFileTool) Parameters() map[string]any {
@@ -509,7 +895,7 @@ func (t *WriteFileTool) Parameters() map[string]any {
},
"content": map[string]any{
"type": "string",
- "description": "Content to write to the file",
+ "description": "Content to write to the file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
},
"overwrite": map[string]any{
"type": "boolean",
@@ -536,7 +922,9 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
if !overwrite {
if _, err := t.fs.Open(path); err == nil {
- return ErrorResult(fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path))
+ return ErrorResult(
+ fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path),
+ )
}
}
diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/fs/filesystem_test.go
similarity index 65%
rename from pkg/tools/filesystem_test.go
rename to pkg/tools/fs/filesystem_test.go
index 0b4dd310b..4387332be 100644
--- a/pkg/tools/filesystem_test.go
+++ b/pkg/tools/fs/filesystem_test.go
@@ -1,4 +1,4 @@
-package tools
+package fstools
import (
"context"
@@ -18,7 +18,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("test content"), 0o644)
- tool := NewReadFileTool("", false, MaxReadFileSize)
+ tool := NewReadFileBytesTool("", false, MaxReadFileSize)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@@ -45,7 +45,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
- tool := NewReadFileTool("", false, MaxReadFileSize)
+ tool := NewReadFileBytesTool("", false, MaxReadFileSize)
ctx := context.Background()
args := map[string]any{
"path": "/nonexistent_file_12345.txt",
@@ -59,8 +59,13 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
}
// Should contain error message
- if !strings.Contains(result.ForLLM, "failed to open file") && !strings.Contains(result.ForUser, "failed to read") {
- t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
+ if !strings.Contains(result.ForLLM, "failed to open file") &&
+ !strings.Contains(result.ForUser, "failed to open") {
+ t.Errorf(
+ "Expected error message, got ForLLM: %s, ForUser: %s",
+ result.ForLLM,
+ result.ForUser,
+ )
}
}
@@ -78,7 +83,8 @@ func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) {
}
// Should mention required parameter
- if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") {
+ if !strings.Contains(result.ForLLM, "path is required") &&
+ !strings.Contains(result.ForUser, "path is required") {
t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM)
}
}
@@ -122,6 +128,45 @@ func TestFilesystemTool_WriteFile_Success(t *testing.T) {
}
}
+// TestFilesystemTool_WriteFile_LiteralBackslashN verifies write_file keeps
+// literal backslash sequences unchanged when they are passed as plain text.
+func TestFilesystemTool_WriteFile_LiteralBackslashN(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "literal.txt")
+
+ tool := NewWriteFileTool("", false)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "content": `aaa\naaa`,
+ })
+
+ assert.False(t, result.IsError, "expected success, got: %s", result.ForLLM)
+
+ data, err := os.ReadFile(testFile)
+ assert.NoError(t, err)
+ assert.Equal(t, `aaa\naaa`, string(data))
+}
+
+// TestFilesystemTool_WriteFile_PreservesCRLF verifies write_file does not
+// normalize line endings and writes CRLF bytes as provided.
+func TestFilesystemTool_WriteFile_PreservesCRLF(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "crlf.txt")
+ content := "line1\r\nline2\r\n"
+
+ tool := NewWriteFileTool("", false)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "content": content,
+ })
+
+ assert.False(t, result.IsError, "expected success, got: %s", result.ForLLM)
+
+ data, err := os.ReadFile(testFile)
+ assert.NoError(t, err)
+ assert.Equal(t, []byte(content), data)
+}
+
// TestFilesystemTool_WriteFile_CreateDir verifies directory creation
func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
tmpDir := t.TempDir()
@@ -297,7 +342,12 @@ func TestFilesystemTool_WriteFile_OverwriteSandboxed(t *testing.T) {
"content": "replaced in sandbox",
"overwrite": true,
})
- assert.False(t, result.IsError, "expected success in sandbox mode with overwrite=true, got: %s", result.ForLLM)
+ assert.False(
+ t,
+ result.IsError,
+ "expected success in sandbox mode with overwrite=true, got: %s",
+ result.ForLLM,
+ )
data, err := os.ReadFile(filepath.Join(workspace, testFile))
assert.NoError(t, err)
@@ -325,7 +375,8 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
}
// Should list files and directories
- if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") {
+ if !strings.Contains(result.ForLLM, "file1.txt") ||
+ !strings.Contains(result.ForLLM, "file2.txt") {
t.Errorf("Expected files in listing, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "subdir") {
@@ -349,8 +400,13 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
}
// Should contain error message
- if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
- t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
+ if !strings.Contains(result.ForLLM, "failed to read") &&
+ !strings.Contains(result.ForUser, "failed to read") {
+ t.Errorf(
+ "Expected error message, got ForLLM: %s, ForUser: %s",
+ result.ForLLM,
+ result.ForUser,
+ )
}
}
@@ -397,7 +453,8 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
// os.Root might return different errors depending on platform/implementation
// but it definitely should error.
// Our wrapper returns "access denied or file not found"
- if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") &&
+ if !strings.Contains(result.ForLLM, "access denied") &&
+ !strings.Contains(result.ForLLM, "file not found") &&
!strings.Contains(result.ForLLM, "no such file") {
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
}
@@ -416,10 +473,20 @@ func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
})
// We EXPECT IsError=true (access blocked due to empty workspace)
- assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM)
+ assert.True(
+ t,
+ result.IsError,
+ "Security Regression: Empty workspace allowed access! content: %s",
+ result.ForLLM,
+ )
// Verify it failed for the right reason
- assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error")
+ assert.Contains(
+ t,
+ result.ForLLM,
+ "workspace is not defined",
+ "Expected 'workspace is not defined' error",
+ )
}
// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases:
@@ -653,7 +720,10 @@ func TestWhitelistFs_BlocksSymlinkEscapeInAllowedDir(t *testing.T) {
patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))}
tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns)
- result := tool.Execute(context.Background(), map[string]any{"path": filepath.Join(linkPath, "secret.txt")})
+ result := tool.Execute(
+ context.Background(),
+ map[string]any{"path": filepath.Join(linkPath, "secret.txt")},
+ )
if !result.IsError {
t.Fatalf("expected symlink escape from allowed dir to be blocked, got: %s", result.ForLLM)
}
@@ -726,7 +796,6 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "pagination_test.txt")
- // Create a test file with exactly 26 bytes of content
fullContent := "abcdefghijklmnopqrstuvwxyz"
err := os.WriteFile(testFile, []byte(fullContent), 0o644)
if err != nil {
@@ -748,15 +817,12 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
t.Fatalf("Chunk 1 failed: %s", result1.ForLLM)
}
- // Expect the first 10 characters
if !strings.Contains(result1.ForLLM, "abcdefghij") {
t.Errorf("Chunk 1 should contain 'abcdefghij', got: %s", result1.ForLLM)
}
- // Expect the header to indicate the file is truncated
if !strings.Contains(result1.ForLLM, "[TRUNCATED") {
t.Errorf("Chunk 1 header should indicate truncation, got: %s", result1.ForLLM)
}
- // Expect the header to suggest the next offset (10)
if !strings.Contains(result1.ForLLM, "offset=10") {
t.Errorf("Chunk 1 header should suggest next offset=10, got: %s", result1.ForLLM)
}
@@ -773,17 +839,14 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
t.Fatalf("Chunk 2 failed: %s", result2.ForLLM)
}
- // Expect the next 10 characters
if !strings.Contains(result2.ForLLM, "klmnopqrst") {
t.Errorf("Chunk 2 should contain 'klmnopqrst', got: %s", result2.ForLLM)
}
- // Expect the header to suggest the next offset (20)
if !strings.Contains(result2.ForLLM, "offset=20") {
t.Errorf("Chunk 2 header should suggest next offset=20, got: %s", result2.ForLLM)
}
// Step 3: Read the final chunk (remaining 6 bytes) ---
- // We ask for 10 bytes, but only 6 are left in the file
args3 := map[string]any{
"path": testFile,
"offset": 20,
@@ -795,16 +858,12 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
t.Fatalf("Chunk 3 failed: %s", result3.ForLLM)
}
- // Expect the last 6 characters
if !strings.Contains(result3.ForLLM, "uvwxyz") {
t.Errorf("Chunk 3 should contain 'uvwxyz', got: %s", result3.ForLLM)
}
- // Expect the header to indicate the end of the file
if !strings.Contains(result3.ForLLM, "[END OF FILE") {
t.Errorf("Chunk 3 header should indicate end of file, got: %s", result3.ForLLM)
}
-
- // Ensure no TRUNCATED message is present in the final chunk
if strings.Contains(result3.ForLLM, "[TRUNCATED") {
t.Errorf("Chunk 3 header should NOT indicate truncation, got: %s", result3.ForLLM)
}
@@ -816,7 +875,6 @@ func TestReadFileTool_OffsetBeyondEOF(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "short.txt")
- // create a file of only 5 bytes
err := os.WriteFile(testFile, []byte("12345"), 0o644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
@@ -827,19 +885,356 @@ func TestReadFileTool_OffsetBeyondEOF(t *testing.T) {
args := map[string]any{
"path": testFile,
- "offset": int64(100), // Offset beyond the end of the file
+ "offset": int64(100),
}
result := tool.Execute(ctx, args)
- // It should not be classified as a tool execution error
if result.IsError {
t.Errorf("A mistake was not expected, obtained IsError=true: %s", result.ForLLM)
}
- // Must return EXACTLY the string provided in the code
expectedMsg := "[END OF FILE - no content at this offset]"
if result.ForLLM != expectedMsg {
t.Errorf("The message %q was expected, obtained: %q", expectedMsg, result.ForLLM)
}
}
+
+func TestReadFileLinesTool_ChunkedReading(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "pagination_lines.txt")
+
+ fullContent := strings.Join([]string{
+ "line 1",
+ "line 2",
+ "line 3",
+ "line 4",
+ "line 5",
+ "line 6",
+ }, "\n") + "\n"
+ err := os.WriteFile(testFile, []byte(fullContent), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+
+ result1 := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ "max_lines": 2,
+ })
+ if result1.IsError {
+ t.Fatalf("Chunk 1 failed: %s", result1.ForLLM)
+ }
+ if !strings.Contains(result1.ForLLM, "1|line 1\n2|line 2\n") {
+ t.Fatalf("expected first two lines, got: %s", result1.ForLLM)
+ }
+ if !strings.Contains(result1.ForLLM, "lines 1-2") {
+ t.Fatalf("expected line range 1-2, got: %s", result1.ForLLM)
+ }
+ if !strings.Contains(result1.ForLLM, "start_line=3") {
+ t.Fatalf("expected continuation start_line=3, got: %s", result1.ForLLM)
+ }
+ if !strings.Contains(result1.ForLLM, "max_lines=2") {
+ t.Fatalf("expected continuation max_lines=2, got: %s", result1.ForLLM)
+ }
+
+ result2 := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 3,
+ "max_lines": 2,
+ })
+ if result2.IsError {
+ t.Fatalf("Chunk 2 failed: %s", result2.ForLLM)
+ }
+ if !strings.Contains(result2.ForLLM, "3|line 3\n4|line 4\n") {
+ t.Fatalf("expected middle chunk, got: %s", result2.ForLLM)
+ }
+ if !strings.Contains(result2.ForLLM, "start_line=5") {
+ t.Fatalf("expected continuation start_line=5, got: %s", result2.ForLLM)
+ }
+ if !strings.Contains(result2.ForLLM, "max_lines=2") {
+ t.Fatalf("expected continuation max_lines=2, got: %s", result2.ForLLM)
+ }
+
+ result3 := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 5,
+ "max_lines": 2,
+ })
+ if result3.IsError {
+ t.Fatalf("Chunk 3 failed: %s", result3.ForLLM)
+ }
+ if !strings.Contains(result3.ForLLM, "5|line 5\n6|line 6\n") {
+ t.Fatalf("expected final chunk, got: %s", result3.ForLLM)
+ }
+ if !strings.Contains(result3.ForLLM, "[END OF FILE") {
+ t.Fatalf("expected EOF marker, got: %s", result3.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_DefaultOffsetAndRemainingLines(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "default_lines.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ })
+ if result.IsError {
+ t.Fatalf("Execute() error = %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "1|line 1\n2|line 2\n3|line 3\n") {
+ t.Fatalf("expected remaining lines by default, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "lines 1-3") {
+ t.Fatalf("expected line range 1-3, got: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileTool_LegacyLengthUsesByteModeForText(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "legacy_bytes.txt")
+
+ err := os.WriteFile(testFile, []byte("abcdefghijklmnopqrstuvwxyz"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileBytesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "offset": 10,
+ "length": 5,
+ })
+ if result.IsError {
+ t.Fatalf("Execute() error = %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "read: bytes 10-14") {
+ t.Fatalf("expected byte-based header, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "klmno") {
+ t.Fatalf("expected byte chunk content, got: %s", result.ForLLM)
+ }
+ if strings.Contains(result.ForLLM, "lines ") {
+ t.Fatalf("expected legacy byte mode, got line-based header: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_OffsetBeyondEOF(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "short_lines.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": int64(100),
+ })
+ if result.IsError {
+ t.Fatalf("unexpected error: %s", result.ForLLM)
+ }
+ if result.ForLLM != "[END OF FILE - no content at or after start_line=100]" {
+ t.Fatalf("unexpected EOF message: %q", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_RejectsOffset(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "legacy_offset.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ "offset": 1,
+ })
+ if !result.IsError {
+ t.Fatalf("expected offset to be rejected, got success: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "offset is not supported in line mode; use start_line") {
+ t.Fatalf("unexpected error for offset in line mode: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_RejectsLength(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "legacy_length.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ "length": 1,
+ })
+ if !result.IsError {
+ t.Fatalf("expected length to be rejected, got success: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "length is not supported in line mode; use max_lines") {
+ t.Fatalf("unexpected error for length in line mode: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_RejectsLimit(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "legacy_limit.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ "limit": 1,
+ })
+ if !result.IsError {
+ t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "limit is not supported in line mode; use max_lines") {
+ t.Fatalf("unexpected error for limit in line mode: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_BinaryFileRejected(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "binary.dat")
+
+ data := []byte{0x00, 0x01, 'A', 'B', 'C', 'D', 'E', 'F'}
+ err := os.WriteFile(testFile, data, 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ })
+ if !result.IsError {
+ t.Fatalf("expected binary file rejection in line mode, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "switch read_file mode to 'bytes'") {
+ t.Fatalf("expected binary file rejection message, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "mode to 'bytes'") {
+ t.Fatalf("expected suggestion to switch read_file mode, got: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_TruncatesSingleLongLineAtByteBudget(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "long_line.txt")
+
+ content := "first line\n" + strings.Repeat("x", 70*1024) + "\n"
+ err := os.WriteFile(testFile, []byte(content), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ })
+ if result.IsError {
+ t.Fatalf("Execute() error = %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "was cut mid-line") {
+ t.Fatalf("expected explicit mid-line truncation warning, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "1|first line\n") {
+ t.Fatalf("expected the first line with line prefix, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "2|") {
+ t.Fatalf("expected line prefix for the truncated line, got: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_NoTrailingNewline(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "no_trailing_newline.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ })
+ if result.IsError {
+ t.Fatalf("Execute() error = %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "1|line 1\n2|line 2") {
+ t.Fatalf(
+ "expected final line without trailing newline to be preserved, got: %s",
+ result.ForLLM,
+ )
+ }
+ if !strings.Contains(result.ForLLM, "[END OF FILE - no further content.]") {
+ t.Fatalf("expected EOF marker, got: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_ExactByteBudgetBoundaryIncludesPrefix(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "exact_boundary.txt")
+
+ err := os.WriteFile(testFile, []byte("1234567\nsecond line\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, 10)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ })
+ if result.IsError {
+ t.Fatalf("Execute() error = %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "1|1234567\n") {
+ t.Fatalf(
+ "expected first line to fit exactly in the byte budget with its prefix, got: %s",
+ result.ForLLM,
+ )
+ }
+ if strings.Contains(result.ForLLM, "2|") {
+ t.Fatalf(
+ "expected second line to be excluded once the exact output byte budget was reached, got: %s",
+ result.ForLLM,
+ )
+ }
+ if !strings.Contains(result.ForLLM, "file_bytes: 8 | output_bytes: 10") {
+ t.Fatalf("expected separate file/output byte counters, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "start_line=2") {
+ t.Fatalf("expected continuation at line 2, got: %s", result.ForLLM)
+ }
+}
diff --git a/pkg/tools/fs/load_image.go b/pkg/tools/fs/load_image.go
new file mode 100644
index 000000000..0a67fa120
--- /dev/null
+++ b/pkg/tools/fs/load_image.go
@@ -0,0 +1,163 @@
+package fstools
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/media"
+)
+
+// LoadImageTool loads a local image file into the MediaStore and returns a
+// media:// reference. The agent loop's resolveMediaRefs will then base64-encode
+// it and attach it as an image_url part in the next LLM request, enabling
+// vision on local files — the same pipeline used when a user sends an image
+// through a chat channel.
+//
+// This is intentionally different from SendFileTool:
+// - SendFileTool → MediaResult + WithResponseHandled() → sends file to user, ends turn
+// - LoadImageTool → plain ToolResult with media:// in ForLLM → LLM sees the image next turn
+type LoadImageTool struct {
+ workspace string
+ restrict bool
+ maxFileSize int
+ mediaStore media.MediaStore
+ allowPaths []*regexp.Regexp
+
+ defaultChannel string
+ defaultChatID string
+}
+
+func NewLoadImageTool(
+ workspace string,
+ restrict bool,
+ maxFileSize int,
+ store media.MediaStore,
+ allowPaths ...[]*regexp.Regexp,
+) *LoadImageTool {
+ if maxFileSize <= 0 {
+ maxFileSize = config.DefaultMaxMediaSize
+ }
+ var patterns []*regexp.Regexp
+ if len(allowPaths) > 0 {
+ patterns = allowPaths[0]
+ }
+ return &LoadImageTool{
+ workspace: workspace,
+ restrict: restrict,
+ maxFileSize: maxFileSize,
+ mediaStore: store,
+ allowPaths: patterns,
+ }
+}
+
+func (t *LoadImageTool) Name() string { return "load_image" }
+
+func (t *LoadImageTool) Description() string {
+ return "Load a local image file so you can analyze its contents with vision. " +
+ "Supported formats: JPEG, PNG, GIF, WebP, BMP. " +
+ "After calling this tool, describe or analyze the image in your next response."
+}
+
+func (t *LoadImageTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "path": map[string]any{
+ "type": "string",
+ "description": "Path to the local image file. Relative paths are resolved from workspace.",
+ },
+ },
+ "required": []string{"path"},
+ }
+}
+
+func (t *LoadImageTool) SetContext(channel, chatID string) {
+ t.defaultChannel = channel
+ t.defaultChatID = chatID
+}
+
+func (t *LoadImageTool) SetMediaStore(store media.MediaStore) {
+ t.mediaStore = store
+}
+
+func (t *LoadImageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ path, _ := args["path"].(string)
+ if strings.TrimSpace(path) == "" {
+ return ErrorResult("path is required")
+ }
+
+ // Prefer context-injected channel/chatID (set by ExecuteWithContext), fall back to SetContext values.
+ channel := ToolChannel(ctx)
+ if channel == "" {
+ channel = t.defaultChannel
+ }
+ chatID := ToolChatID(ctx)
+ if chatID == "" {
+ chatID = t.defaultChatID
+ }
+ if channel == "" || chatID == "" {
+ return ErrorResult("no target channel/chat available")
+ }
+
+ if t.mediaStore == nil {
+ return ErrorResult("media store not configured")
+ }
+
+ resolved, err := validatePathWithAllowPaths(path, t.workspace, t.restrict, t.allowPaths)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("invalid path: %v", err))
+ }
+
+ info, err := os.Stat(resolved)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("file not found: %v", err))
+ }
+ if info.IsDir() {
+ return ErrorResult("path is a directory, expected an image file")
+ }
+ if info.Size() > int64(t.maxFileSize) {
+ return ErrorResult(fmt.Sprintf(
+ "file too large: %d bytes (max %d bytes)", info.Size(), t.maxFileSize,
+ ))
+ }
+
+ // Detect MIME type — reuse the helper already in send_file.go
+ mediaType := detectMediaType(resolved)
+ if !strings.HasPrefix(mediaType, "image/") {
+ return ErrorResult(fmt.Sprintf(
+ "file does not appear to be an image (detected type: %s)", mediaType,
+ ))
+ }
+
+ filename := filepath.Base(resolved)
+ scope := fmt.Sprintf("tool:load_image:%s:%s", channel, chatID)
+
+ ref, err := t.mediaStore.Store(resolved, media.MediaMeta{
+ Filename: filename,
+ ContentType: mediaType,
+ Source: "tool:load_image",
+ CleanupPolicy: media.CleanupPolicyForgetOnly,
+ }, scope)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to register image in media store: %v", err))
+ }
+
+ // Build the tool result text. The media:// ref in Media will be picked
+ // up by resolveMediaRefs in agent_media.go and base64-encoded for tool
+ // result messages (role="tool"), so the LLM can see the image content.
+ msg := fmt.Sprintf("Image loaded: %s\n[image: photo]", filename)
+
+ return &ToolResult{
+ ForLLM: msg,
+ ForUser: fmt.Sprintf("Loaded image: %s", filename),
+ // Media refs inside ForLLM are resolved by resolveMediaRefs in the
+ // agent loop before the next LLM call. Do NOT use MediaResult here —
+ // that would send the file to the user channel instead.
+ Media: []string{ref},
+ }
+}
diff --git a/pkg/tools/fs/load_image_test.go b/pkg/tools/fs/load_image_test.go
new file mode 100644
index 000000000..d33db73be
--- /dev/null
+++ b/pkg/tools/fs/load_image_test.go
@@ -0,0 +1,152 @@
+package fstools
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/media"
+)
+
+func TestLoadImage_PathRequired(t *testing.T) {
+ tool := NewLoadImageTool("/tmp", false, 0, nil)
+ ctx := WithToolContext(context.Background(), "test", "chat1")
+ result := tool.Execute(ctx, map[string]any{})
+ if !result.IsError {
+ t.Fatal("expected error for missing path")
+ }
+}
+
+func TestLoadImage_NilMediaStore(t *testing.T) {
+ tool := NewLoadImageTool("/tmp", false, 0, nil)
+ ctx := WithToolContext(context.Background(), "test", "chat1")
+ result := tool.Execute(ctx, map[string]any{"path": "test.png"})
+ if !result.IsError || result.ForLLM != "media store not configured" {
+ t.Fatalf("expected media store error, got: %s", result.ForLLM)
+ }
+}
+
+func TestLoadImage_NoChannelContext(t *testing.T) {
+ store := media.NewFileMediaStore()
+ tool := NewLoadImageTool("/tmp", false, 0, store)
+ // No WithToolContext — should fail
+ result := tool.Execute(context.Background(), map[string]any{"path": "test.png"})
+ if !result.IsError || result.ForLLM != "no target channel/chat available" {
+ t.Fatalf("expected channel error, got: %s", result.ForLLM)
+ }
+}
+
+func TestLoadImage_NonImageFile(t *testing.T) {
+ dir := t.TempDir()
+ txtFile := filepath.Join(dir, "readme.txt")
+ os.WriteFile(txtFile, []byte("hello"), 0o644)
+
+ store := media.NewFileMediaStore()
+ tool := NewLoadImageTool(dir, false, 0, store)
+ ctx := WithToolContext(context.Background(), "test", "chat1")
+ result := tool.Execute(ctx, map[string]any{"path": txtFile})
+ if !result.IsError {
+ t.Fatal("expected error for non-image file")
+ }
+}
+
+func TestLoadImage_DefaultMaxSize(t *testing.T) {
+ tool := NewLoadImageTool("/tmp", false, 0, nil)
+ if tool.maxFileSize != config.DefaultMaxMediaSize {
+ t.Errorf("expected default max size %d, got %d", config.DefaultMaxMediaSize, tool.maxFileSize)
+ }
+}
+
+func TestLoadImage_FileTooLarge(t *testing.T) {
+ dir := t.TempDir()
+ bigFile := filepath.Join(dir, "big.png")
+ // Create a file with PNG header but exceeding max size
+ data := make([]byte, 1024)
+ copy(data, []byte{0x89, 0x50, 0x4E, 0x47}) // PNG magic bytes
+ os.WriteFile(bigFile, data, 0o644)
+
+ store := media.NewFileMediaStore()
+ tool := NewLoadImageTool(dir, false, 512, store) // maxSize = 512
+ ctx := WithToolContext(context.Background(), "test", "chat1")
+ result := tool.Execute(ctx, map[string]any{"path": bigFile})
+ if !result.IsError {
+ t.Fatal("expected error for oversized file")
+ }
+}
+
+func TestLoadImage_SuccessPath(t *testing.T) {
+ dir := t.TempDir()
+
+ // Create a minimal valid PNG file (8-byte signature + minimal IHDR + IEND).
+ // The PNG spec requires the 8-byte magic header: 0x89 P N G \r \n 0x1a \n
+ pngSignature := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
+ // IHDR chunk: length(13) + "IHDR" + 1x1 px, 8-bit RGB, no interlace + CRC
+ ihdr := []byte{
+ 0x00, 0x00, 0x00, 0x0D, // chunk length = 13
+ 0x49, 0x48, 0x44, 0x52, // "IHDR"
+ 0x00, 0x00, 0x00, 0x01, // width = 1
+ 0x00, 0x00, 0x00, 0x01, // height = 1
+ 0x08, // bit depth = 8
+ 0x02, // color type = RGB
+ 0x00, 0x00, 0x00, // compression, filter, interlace
+ 0x90, 0x77, 0x53, 0xDE, // CRC (valid for this IHDR)
+ }
+ // IEND chunk
+ iend := []byte{
+ 0x00, 0x00, 0x00, 0x00, // chunk length = 0
+ 0x49, 0x45, 0x4E, 0x44, // "IEND"
+ 0xAE, 0x42, 0x60, 0x82, // CRC
+ }
+
+ pngData := make([]byte, 0, len(pngSignature)+len(ihdr)+len(iend))
+ pngData = append(pngData, pngSignature...)
+ pngData = append(pngData, ihdr...)
+ pngData = append(pngData, iend...)
+
+ imgPath := filepath.Join(dir, "test_image.png")
+ if err := os.WriteFile(imgPath, pngData, 0o644); err != nil {
+ t.Fatalf("failed to create test PNG: %v", err)
+ }
+
+ store := media.NewFileMediaStore()
+ tool := NewLoadImageTool(dir, false, 0, store)
+ ctx := WithToolContext(context.Background(), "test", "chat1")
+
+ result := tool.Execute(ctx, map[string]any{"path": imgPath})
+
+ // 1. Must not be an error
+ if result.IsError {
+ t.Fatalf("expected success, got error: %s", result.ForLLM)
+ }
+
+ // 2. Media must contain exactly one media:// ref
+ if len(result.Media) != 1 {
+ t.Fatalf("expected 1 media ref, got %d", len(result.Media))
+ }
+ if !strings.HasPrefix(result.Media[0], "media://") {
+ t.Errorf("expected media ref to start with 'media://', got: %s", result.Media[0])
+ }
+
+ // 3. ForLLM must contain the [image: marker
+ if !strings.Contains(result.ForLLM, "[image:") {
+ t.Errorf("expected ForLLM to contain '[image:' marker, got: %s", result.ForLLM)
+ }
+
+ // 4. ForLLM should contain the generic [image: photo] placeholder
+ // (resolveMediaRefs will replace it with the actual path later)
+ if !strings.Contains(result.ForLLM, "[image: photo]") {
+ t.Errorf("expected ForLLM to contain '[image: photo]' placeholder, got: %s", result.ForLLM)
+ }
+
+ // 5. Verify the ref is resolvable in the store
+ resolved, err := store.Resolve(result.Media[0])
+ if err != nil {
+ t.Fatalf("media ref not resolvable: %v", err)
+ }
+ if resolved != imgPath {
+ t.Errorf("expected resolved path %q, got %q", imgPath, resolved)
+ }
+}
diff --git a/pkg/tools/send_file.go b/pkg/tools/fs/send_file.go
similarity index 99%
rename from pkg/tools/send_file.go
rename to pkg/tools/fs/send_file.go
index 44198381e..e4f90bf61 100644
--- a/pkg/tools/send_file.go
+++ b/pkg/tools/fs/send_file.go
@@ -1,4 +1,4 @@
-package tools
+package fstools
import (
"context"
diff --git a/pkg/tools/send_file_test.go b/pkg/tools/fs/send_file_test.go
similarity index 99%
rename from pkg/tools/send_file_test.go
rename to pkg/tools/fs/send_file_test.go
index f36baf7d0..771393b75 100644
--- a/pkg/tools/send_file_test.go
+++ b/pkg/tools/fs/send_file_test.go
@@ -1,4 +1,4 @@
-package tools
+package fstools
import (
"context"
diff --git a/pkg/tools/fs/shared.go b/pkg/tools/fs/shared.go
new file mode 100644
index 000000000..6d46e692b
--- /dev/null
+++ b/pkg/tools/fs/shared.go
@@ -0,0 +1,37 @@
+package fstools
+
+import (
+ "context"
+
+ toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
+)
+
+type ToolResult = toolshared.ToolResult
+
+func WithToolContext(ctx context.Context, channel, chatID string) context.Context {
+ return toolshared.WithToolContext(ctx, channel, chatID)
+}
+
+func ToolChannel(ctx context.Context) string {
+ return toolshared.ToolChannel(ctx)
+}
+
+func ToolChatID(ctx context.Context) string {
+ return toolshared.ToolChatID(ctx)
+}
+
+func ErrorResult(message string) *ToolResult {
+ return toolshared.ErrorResult(message)
+}
+
+func NewToolResult(forLLM string) *ToolResult {
+ return toolshared.NewToolResult(forLLM)
+}
+
+func SilentResult(forLLM string) *ToolResult {
+ return toolshared.SilentResult(forLLM)
+}
+
+func MediaResult(forLLM string, mediaRefs []string) *ToolResult {
+ return toolshared.MediaResult(forLLM, mediaRefs)
+}
diff --git a/pkg/tools/fs_facade.go b/pkg/tools/fs_facade.go
new file mode 100644
index 000000000..5ed68f04c
--- /dev/null
+++ b/pkg/tools/fs_facade.go
@@ -0,0 +1,100 @@
+package tools
+
+import (
+ "regexp"
+
+ "github.com/sipeed/picoclaw/pkg/media"
+ fstools "github.com/sipeed/picoclaw/pkg/tools/fs"
+)
+
+type (
+ ReadFileTool = fstools.ReadFileTool
+ ReadFileLinesTool = fstools.ReadFileLinesTool
+ WriteFileTool = fstools.WriteFileTool
+ ListDirTool = fstools.ListDirTool
+ EditFileTool = fstools.EditFileTool
+ AppendFileTool = fstools.AppendFileTool
+ LoadImageTool = fstools.LoadImageTool
+ SendFileTool = fstools.SendFileTool
+)
+
+const MaxReadFileSize = fstools.MaxReadFileSize
+
+func NewReadFileTool(
+ workspace string,
+ restrict bool,
+ maxReadFileSize int,
+ allowPaths ...[]*regexp.Regexp,
+) *ReadFileTool {
+ return fstools.NewReadFileTool(workspace, restrict, maxReadFileSize, allowPaths...)
+}
+
+func NewReadFileBytesTool(
+ workspace string,
+ restrict bool,
+ maxReadFileSize int,
+ allowPaths ...[]*regexp.Regexp,
+) *ReadFileTool {
+ return fstools.NewReadFileBytesTool(workspace, restrict, maxReadFileSize, allowPaths...)
+}
+
+func NewReadFileLinesTool(
+ workspace string,
+ restrict bool,
+ maxReadFileSize int,
+ allowPaths ...[]*regexp.Regexp,
+) *ReadFileLinesTool {
+ return fstools.NewReadFileLinesTool(workspace, restrict, maxReadFileSize, allowPaths...)
+}
+
+func NewWriteFileTool(
+ workspace string,
+ restrict bool,
+ allowPaths ...[]*regexp.Regexp,
+) *WriteFileTool {
+ return fstools.NewWriteFileTool(workspace, restrict, allowPaths...)
+}
+
+func NewListDirTool(
+ workspace string,
+ restrict bool,
+ allowPaths ...[]*regexp.Regexp,
+) *ListDirTool {
+ return fstools.NewListDirTool(workspace, restrict, allowPaths...)
+}
+
+func NewEditFileTool(
+ workspace string,
+ restrict bool,
+ allowPaths ...[]*regexp.Regexp,
+) *EditFileTool {
+ return fstools.NewEditFileTool(workspace, restrict, allowPaths...)
+}
+
+func NewAppendFileTool(
+ workspace string,
+ restrict bool,
+ allowPaths ...[]*regexp.Regexp,
+) *AppendFileTool {
+ return fstools.NewAppendFileTool(workspace, restrict, allowPaths...)
+}
+
+func NewLoadImageTool(
+ workspace string,
+ restrict bool,
+ maxFileSize int,
+ store media.MediaStore,
+ allowPaths ...[]*regexp.Regexp,
+) *LoadImageTool {
+ return fstools.NewLoadImageTool(workspace, restrict, maxFileSize, store, allowPaths...)
+}
+
+func NewSendFileTool(
+ workspace string,
+ restrict bool,
+ maxFileSize int,
+ store media.MediaStore,
+ allowPaths ...[]*regexp.Regexp,
+) *SendFileTool {
+ return fstools.NewSendFileTool(workspace, restrict, maxFileSize, store, allowPaths...)
+}
diff --git a/pkg/tools/fs_registry_compat_test.go b/pkg/tools/fs_registry_compat_test.go
new file mode 100644
index 000000000..51e080217
--- /dev/null
+++ b/pkg/tools/fs_registry_compat_test.go
@@ -0,0 +1,46 @@
+package tools
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestReadFileLinesTool_RegistryValidationSupportsMaxLinesAndRejectsLimit(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "registry_lines.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ reg := NewToolRegistry()
+ reg.Register(NewReadFileLinesTool(tmpDir, false, MaxReadFileSize))
+
+ result := reg.Execute(context.Background(), "read_file", map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ "max_lines": 1,
+ })
+ if result.IsError {
+ t.Fatalf("expected max_lines to pass registry validation, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "1|line 1\n") {
+ t.Fatalf("expected first line via max_lines, got: %s", result.ForLLM)
+ }
+
+ result = reg.Execute(context.Background(), "read_file", map[string]any{
+ "path": testFile,
+ "start_line": 2,
+ "limit": 1,
+ })
+ if !result.IsError {
+ t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "unexpected property \"limit\"") {
+ t.Fatalf("expected registry validation error for limit, got: %s", result.ForLLM)
+ }
+}
diff --git a/pkg/tools/i2c.go b/pkg/tools/hardware/i2c.go
similarity index 97%
rename from pkg/tools/i2c.go
rename to pkg/tools/hardware/i2c.go
index 779b1d5a7..62e9557ee 100644
--- a/pkg/tools/i2c.go
+++ b/pkg/tools/hardware/i2c.go
@@ -1,4 +1,4 @@
-package tools
+package hardwaretools
import (
"context"
@@ -120,16 +120,12 @@ func (t *I2CTool) detect() *ToolResult {
// Helper functions for I2C operations (used by platform-specific implementations)
// isValidBusID checks that a bus identifier is a simple number (prevents path injection)
-//
-//nolint:unused // Used by i2c_linux.go
func isValidBusID(id string) bool {
matched, _ := regexp.MatchString(`^\d+$`, id)
return matched
}
// parseI2CAddress extracts and validates an I2C address from args
-//
-//nolint:unused // Used by i2c_linux.go
func parseI2CAddress(args map[string]any) (int, *ToolResult) {
addrFloat, ok := args["address"].(float64)
if !ok {
@@ -143,8 +139,6 @@ func parseI2CAddress(args map[string]any) (int, *ToolResult) {
}
// parseI2CBus extracts and validates an I2C bus from args
-//
-//nolint:unused // Used by i2c_linux.go
func parseI2CBus(args map[string]any) (string, *ToolResult) {
bus, ok := args["bus"].(string)
if !ok || bus == "" {
@@ -155,3 +149,9 @@ func parseI2CBus(args map[string]any) (string, *ToolResult) {
}
return bus, nil
}
+
+var (
+ _ = isValidBusID
+ _ = parseI2CAddress
+ _ = parseI2CBus
+)
diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/hardware/i2c_linux.go
similarity index 99%
rename from pkg/tools/i2c_linux.go
rename to pkg/tools/hardware/i2c_linux.go
index 4eaaf8f09..771d11d90 100644
--- a/pkg/tools/i2c_linux.go
+++ b/pkg/tools/hardware/i2c_linux.go
@@ -1,4 +1,4 @@
-package tools
+package hardwaretools
import (
"encoding/json"
diff --git a/pkg/tools/i2c_other.go b/pkg/tools/hardware/i2c_other.go
similarity index 95%
rename from pkg/tools/i2c_other.go
rename to pkg/tools/hardware/i2c_other.go
index 7becf8339..4a0a130e0 100644
--- a/pkg/tools/i2c_other.go
+++ b/pkg/tools/hardware/i2c_other.go
@@ -1,6 +1,6 @@
//go:build !linux
-package tools
+package hardwaretools
// scan is a stub for non-Linux platforms.
func (t *I2CTool) scan(args map[string]any) *ToolResult {
diff --git a/pkg/tools/hardware/serial.go b/pkg/tools/hardware/serial.go
new file mode 100644
index 000000000..7e197a909
--- /dev/null
+++ b/pkg/tools/hardware/serial.go
@@ -0,0 +1,453 @@
+package hardwaretools
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math"
+ "regexp"
+ "runtime"
+ "strings"
+ "time"
+ "unicode/utf8"
+)
+
+const (
+ defaultSerialBaud = 115200
+ defaultSerialDataBits = 8
+ defaultSerialStopBits = 1
+ defaultSerialTimeoutMS = 1000
+ maxSerialPayloadBytes = 4096
+ maxSerialReadBytes = 4096
+ serialPollInterval = 100 * time.Millisecond
+)
+
+var (
+ unixSerialPortPattern = regexp.MustCompile(
+ `^(?:/dev/)?(?:ttyS\d+|ttyUSB\d+|ttyACM\d+|ttyAMA\d+|rfcomm\d+|tty\.[A-Za-z0-9._-]+|cu\.[A-Za-z0-9._-]+)$`,
+ )
+ windowsSerialPortPattern = regexp.MustCompile(`^(?:\\\\\.\\)?COM[1-9]\d*$`)
+ unixSerialBaudRates = map[int]struct{}{
+ 50: {}, 75: {}, 110: {}, 134: {}, 150: {}, 200: {}, 300: {}, 600: {}, 1200: {}, 1800: {},
+ 2400: {}, 4800: {}, 9600: {}, 19200: {}, 38400: {}, 57600: {}, 115200: {}, 230400: {},
+ }
+)
+
+type SerialTool struct{}
+
+type serialPortInfo struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+}
+
+type serialConfig struct {
+ Port string
+ Baud int
+ DataBits int
+ Parity string
+ StopBits int
+}
+
+func NewSerialTool() *SerialTool {
+ return &SerialTool{}
+}
+
+func (t *SerialTool) Name() string {
+ return "serial"
+}
+
+func (t *SerialTool) Description() string {
+ return "Interact with host serial ports. Actions: list (enumerate ports), read (receive bytes), write (send bytes with explicit confirmation). Supports Linux, macOS, and Windows."
+}
+
+func (t *SerialTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "action": map[string]any{
+ "type": "string",
+ "enum": []string{"list", "read", "write"},
+ "description": "Action to perform: list available serial ports, read bytes from a port, or write bytes to a port.",
+ },
+ "port": map[string]any{
+ "type": "string",
+ "description": "Serial port path or name, for example /dev/ttyUSB0, /dev/cu.usbserial-0001, or COM3. Required for read/write.",
+ },
+ "baud": map[string]any{
+ "type": "integer",
+ "description": "Baud rate. Default: 115200. Linux/macOS currently support standard termios rates up to 230400; Windows accepts configured rates up to 4000000.",
+ },
+ "data_bits": map[string]any{
+ "type": "integer",
+ "description": "Data bits. Supported values: 5, 6, 7, 8. Default: 8.",
+ },
+ "parity": map[string]any{
+ "type": "string",
+ "enum": []string{"none", "even", "odd"},
+ "description": "Parity mode. Default: none.",
+ },
+ "stop_bits": map[string]any{
+ "type": "integer",
+ "description": "Stop bits. Supported values: 1, 2. Default: 1.",
+ },
+ "timeout_ms": map[string]any{
+ "type": "integer",
+ "description": "Read/write timeout in milliseconds. Default: 1000.",
+ },
+ "length": map[string]any{
+ "type": "integer",
+ "description": "Number of bytes to read. Required for read. Range: 1-4096.",
+ },
+ "data": map[string]any{
+ "type": "array",
+ "items": map[string]any{"type": "integer"},
+ "description": "Bytes to write, each in range 0-255. Required for write unless text is provided.",
+ },
+ "text": map[string]any{
+ "type": "string",
+ "description": "UTF-8 text to write. Required for write if data is omitted.",
+ },
+ "confirm": map[string]any{
+ "type": "boolean",
+ "description": "Must be true for write operations.",
+ },
+ },
+ "required": []string{"action"},
+ }
+}
+
+func (t *SerialTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ action, ok := args["action"].(string)
+ if !ok || strings.TrimSpace(action) == "" {
+ return ErrorResult("action is required")
+ }
+
+ switch action {
+ case "list":
+ return t.list()
+ case "read":
+ return t.read(ctx, args)
+ case "write":
+ return t.write(ctx, args)
+ default:
+ return ErrorResult(fmt.Sprintf("unknown action: %s (valid: list, read, write)", action))
+ }
+}
+
+func (t *SerialTool) list() *ToolResult {
+ ports, err := serialListPorts()
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to list serial ports: %v", err))
+ }
+ if len(ports) == 0 {
+ return SilentResult("No serial ports found on this host.")
+ }
+
+ result, _ := json.MarshalIndent(map[string]any{
+ "ports": ports,
+ "count": len(ports),
+ }, "", " ")
+ return SilentResult(string(result))
+}
+
+func (t *SerialTool) read(ctx context.Context, args map[string]any) *ToolResult {
+ cfg, errResult := parseSerialConfig(args)
+ if errResult != nil {
+ return errResult
+ }
+
+ length := 0
+ if v, ok := args["length"].(float64); ok {
+ length = int(v)
+ }
+ if length < 1 || length > maxSerialReadBytes {
+ return ErrorResult(fmt.Sprintf("length is required for read (1-%d)", maxSerialReadBytes))
+ }
+
+ timeout, errResult := parseSerialTimeout(args)
+ if errResult != nil {
+ return errResult
+ }
+
+ data, err := serialRead(ctx, cfg, length, timeout)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("serial read failed on %s: %v", cfg.Port, err))
+ }
+
+ return SilentResult(formatSerialPayload("read", cfg, data, timeout))
+}
+
+func (t *SerialTool) write(ctx context.Context, args map[string]any) *ToolResult {
+ confirm, _ := args["confirm"].(bool)
+ if !confirm {
+ return ErrorResult(
+ "write operations require confirm: true. Please confirm with the user before sending bytes to a serial device.",
+ )
+ }
+
+ cfg, errResult := parseSerialConfig(args)
+ if errResult != nil {
+ return errResult
+ }
+ timeout, errResult := parseSerialTimeout(args)
+ if errResult != nil {
+ return errResult
+ }
+ payload, errResult := parseSerialWritePayload(args)
+ if errResult != nil {
+ return errResult
+ }
+
+ written, err := serialWrite(ctx, cfg, payload, timeout)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("serial write failed on %s: %v", cfg.Port, err))
+ }
+
+ result, _ := json.MarshalIndent(map[string]any{
+ "action": "write",
+ "port": cfg.Port,
+ "baud": cfg.Baud,
+ "data_bits": cfg.DataBits,
+ "parity": cfg.Parity,
+ "stop_bits": cfg.StopBits,
+ "timeout_ms": timeout.Milliseconds(),
+ "written": written,
+ "payload": serialPayloadSummary(payload),
+ }, "", " ")
+ return SilentResult(string(result))
+}
+
+func parseSerialConfig(args map[string]any) (serialConfig, *ToolResult) {
+ port, ok := args["port"].(string)
+ port = strings.TrimSpace(port)
+ if !ok || port == "" {
+ return serialConfig{}, ErrorResult(
+ "port is required (for example /dev/ttyUSB0, /dev/cu.usbserial-0001, or COM3)",
+ )
+ }
+
+ normalizedPort, err := normalizeSerialPort(port)
+ if err != nil {
+ return serialConfig{}, ErrorResult(err.Error())
+ }
+
+ cfg := serialConfig{
+ Port: normalizedPort,
+ Baud: defaultSerialBaud,
+ DataBits: defaultSerialDataBits,
+ Parity: "none",
+ StopBits: defaultSerialStopBits,
+ }
+
+ if v, ok := args["baud"].(float64); ok {
+ cfg.Baud = int(v)
+ }
+ if err := validateSerialBaud(cfg.Baud); err != nil {
+ return serialConfig{}, ErrorResult(err.Error())
+ }
+
+ if v, ok := args["data_bits"].(float64); ok {
+ cfg.DataBits = int(v)
+ }
+ switch cfg.DataBits {
+ case 5, 6, 7, 8:
+ default:
+ return serialConfig{}, ErrorResult("data_bits must be one of 5, 6, 7, or 8")
+ }
+
+ if v, ok := args["parity"].(string); ok && strings.TrimSpace(v) != "" {
+ cfg.Parity = strings.ToLower(strings.TrimSpace(v))
+ }
+ switch cfg.Parity {
+ case "none", "even", "odd":
+ default:
+ return serialConfig{}, ErrorResult(`parity must be one of "none", "even", or "odd"`)
+ }
+
+ if v, ok := args["stop_bits"].(float64); ok {
+ cfg.StopBits = int(v)
+ }
+ if cfg.StopBits != 1 && cfg.StopBits != 2 {
+ return serialConfig{}, ErrorResult("stop_bits must be 1 or 2")
+ }
+
+ return cfg, nil
+}
+
+func parseSerialTimeout(args map[string]any) (time.Duration, *ToolResult) {
+ timeoutMS := defaultSerialTimeoutMS
+ if v, ok := args["timeout_ms"].(float64); ok {
+ timeoutMS = int(v)
+ }
+ if timeoutMS < 1 || timeoutMS > 60000 {
+ return 0, ErrorResult("timeout_ms must be between 1 and 60000")
+ }
+ return time.Duration(timeoutMS) * time.Millisecond, nil
+}
+
+func parseSerialWritePayload(args map[string]any) ([]byte, *ToolResult) {
+ if text, ok := args["text"].(string); ok && text != "" {
+ if !utf8.ValidString(text) {
+ return nil, ErrorResult("text must be valid UTF-8")
+ }
+ if len(text) > maxSerialPayloadBytes {
+ return nil, ErrorResult(fmt.Sprintf("text payload too large: maximum %d bytes", maxSerialPayloadBytes))
+ }
+ return []byte(text), nil
+ }
+
+ dataRaw, ok := args["data"].([]any)
+ if !ok || len(dataRaw) == 0 {
+ return nil, ErrorResult("write requires either text or data")
+ }
+ if len(dataRaw) > maxSerialPayloadBytes {
+ return nil, ErrorResult(fmt.Sprintf("data too long: maximum %d bytes", maxSerialPayloadBytes))
+ }
+
+ data := make([]byte, len(dataRaw))
+ for i, v := range dataRaw {
+ f, ok := v.(float64)
+ if !ok {
+ return nil, ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i))
+ }
+ if f != math.Trunc(f) {
+ return nil, ErrorResult(fmt.Sprintf("data[%d] is not an integer byte value", i))
+ }
+ b := int(f)
+ if b < 0 || b > 255 {
+ return nil, ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b))
+ }
+ data[i] = byte(b)
+ }
+
+ return data, nil
+}
+
+func formatSerialPayload(action string, cfg serialConfig, data []byte, timeout time.Duration) string {
+ result, _ := json.MarshalIndent(map[string]any{
+ "action": action,
+ "port": cfg.Port,
+ "baud": cfg.Baud,
+ "data_bits": cfg.DataBits,
+ "parity": cfg.Parity,
+ "stop_bits": cfg.StopBits,
+ "timeout_ms": timeout.Milliseconds(),
+ "payload": serialPayloadSummary(data),
+ }, "", " ")
+ return string(result)
+}
+
+func serialPayloadSummary(data []byte) map[string]any {
+ hexValues := make([]string, len(data))
+ intValues := make([]int, len(data))
+ for i, b := range data {
+ hexValues[i] = fmt.Sprintf("0x%02x", b)
+ intValues[i] = int(b)
+ }
+
+ summary := map[string]any{
+ "length": len(data),
+ "bytes": intValues,
+ "hex": hexValues,
+ }
+ if utf8.Valid(data) {
+ summary["text"] = string(data)
+ }
+ return summary
+}
+
+func normalizeSerialPort(port string) (string, error) {
+ switch runtime.GOOS {
+ case "windows":
+ return normalizeWindowsSerialPath(port)
+ case "linux", "darwin":
+ return normalizeUnixSerialPath(port)
+ default:
+ if normalized, err := normalizeUnixSerialPath(port); err == nil {
+ return normalized, nil
+ }
+ return normalizeWindowsSerialPath(port)
+ }
+}
+
+func normalizeUnixSerialPath(port string) (string, error) {
+ trimmed := strings.TrimSpace(port)
+ if !unixSerialPortPattern.MatchString(trimmed) {
+ return "", fmt.Errorf(
+ "invalid serial port: expected a safe Unix device name such as /dev/ttyUSB0 or /dev/cu.usbserial-0001",
+ )
+ }
+ if strings.HasPrefix(trimmed, "/dev/") {
+ return trimmed, nil
+ }
+ return "/dev/" + trimmed, nil
+}
+
+func normalizeWindowsSerialPath(port string) (string, error) {
+ trimmed := strings.ToUpper(strings.TrimSpace(port))
+ if !windowsSerialPortPattern.MatchString(trimmed) {
+ return "", fmt.Errorf("invalid serial port: expected a COM port such as COM3")
+ }
+ if strings.HasPrefix(trimmed, `\\.\`) {
+ return trimmed, nil
+ }
+ return `\\.\` + trimmed, nil
+}
+
+func validateSerialBaud(baud int) error {
+ if baud < 50 || baud > 4000000 {
+ return fmt.Errorf("baud must be between 50 and 4000000")
+ }
+
+ switch runtime.GOOS {
+ case "linux", "darwin":
+ if _, ok := unixSerialBaudRates[baud]; !ok {
+ return fmt.Errorf("unsupported baud rate on this platform: %d (supported up to 230400)", baud)
+ }
+ }
+
+ return nil
+}
+
+func serialContextErr(ctx context.Context) error {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ return nil
+ }
+}
+
+func serialWriteAll(
+ ctx context.Context,
+ data []byte,
+ timeout time.Duration,
+ now func() time.Time,
+ write func([]byte) (int, error),
+) (int, error) {
+ if err := serialContextErr(ctx); err != nil {
+ return 0, err
+ }
+
+ total := 0
+ deadline := now().Add(timeout)
+ for total < len(data) {
+ if err := serialContextErr(ctx); err != nil {
+ return total, err
+ }
+ if deadline.Sub(now()) <= 0 {
+ return total, fmt.Errorf("timeout while writing serial data")
+ }
+
+ n, err := write(data[total:])
+ total += n
+ if err != nil {
+ return total, err
+ }
+ if n == 0 {
+ continue
+ }
+ }
+
+ return total, nil
+}
diff --git a/pkg/tools/hardware/serial_darwin.go b/pkg/tools/hardware/serial_darwin.go
new file mode 100644
index 000000000..bc019029e
--- /dev/null
+++ b/pkg/tools/hardware/serial_darwin.go
@@ -0,0 +1,19 @@
+//go:build darwin
+
+package hardwaretools
+
+import "golang.org/x/sys/unix"
+
+func serialGetTermios(fd int) (*unix.Termios, error) {
+ return unix.IoctlGetTermios(fd, unix.TIOCGETA)
+}
+
+func serialSetSpeed(tio *unix.Termios, speed uint32) error {
+ tio.Ispeed = uint64(speed)
+ tio.Ospeed = uint64(speed)
+ return nil
+}
+
+func serialSetTermios(fd int, tio *unix.Termios) error {
+ return unix.IoctlSetTermios(fd, unix.TIOCSETA, tio)
+}
diff --git a/pkg/tools/hardware/serial_linux.go b/pkg/tools/hardware/serial_linux.go
new file mode 100644
index 000000000..bad3e4cb8
--- /dev/null
+++ b/pkg/tools/hardware/serial_linux.go
@@ -0,0 +1,19 @@
+//go:build linux
+
+package hardwaretools
+
+import "golang.org/x/sys/unix"
+
+func serialGetTermios(fd int) (*unix.Termios, error) {
+ return unix.IoctlGetTermios(fd, unix.TCGETS)
+}
+
+func serialSetSpeed(tio *unix.Termios, speed uint32) error {
+ tio.Ispeed = speed
+ tio.Ospeed = speed
+ return nil
+}
+
+func serialSetTermios(fd int, tio *unix.Termios) error {
+ return unix.IoctlSetTermios(fd, unix.TCSETS, tio)
+}
diff --git a/pkg/tools/hardware/serial_other.go b/pkg/tools/hardware/serial_other.go
new file mode 100644
index 000000000..ec72a2d2a
--- /dev/null
+++ b/pkg/tools/hardware/serial_other.go
@@ -0,0 +1,21 @@
+//go:build !linux && !darwin && !windows
+
+package hardwaretools
+
+import (
+ "context"
+ "fmt"
+ "time"
+)
+
+func serialListPorts() ([]serialPortInfo, error) {
+ return nil, fmt.Errorf("serial is not supported on this platform")
+}
+
+func serialRead(ctx context.Context, cfg serialConfig, length int, timeout time.Duration) ([]byte, error) {
+ return nil, fmt.Errorf("serial is not supported on this platform")
+}
+
+func serialWrite(ctx context.Context, cfg serialConfig, data []byte, timeout time.Duration) (int, error) {
+ return 0, fmt.Errorf("serial is not supported on this platform")
+}
diff --git a/pkg/tools/hardware/serial_other_test.go b/pkg/tools/hardware/serial_other_test.go
new file mode 100644
index 000000000..ef04c4062
--- /dev/null
+++ b/pkg/tools/hardware/serial_other_test.go
@@ -0,0 +1,18 @@
+//go:build !linux && !darwin && !windows
+
+package hardwaretools
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestSerialListPortsUnsupportedPlatform(t *testing.T) {
+ _, err := serialListPorts()
+ if err == nil {
+ t.Fatal("expected unsupported platform error")
+ }
+ if !strings.Contains(err.Error(), "not supported") {
+ t.Fatalf("serialListPorts() error = %v, want unsupported platform message", err)
+ }
+}
diff --git a/pkg/tools/hardware/serial_test.go b/pkg/tools/hardware/serial_test.go
new file mode 100644
index 000000000..6b2e9765d
--- /dev/null
+++ b/pkg/tools/hardware/serial_test.go
@@ -0,0 +1,269 @@
+package hardwaretools
+
+import (
+ "context"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestParseSerialConfig(t *testing.T) {
+ port := "/dev/ttyUSB0"
+ if runtime.GOOS == "windows" {
+ port = "COM3"
+ }
+
+ cfg, errResult := parseSerialConfig(map[string]any{
+ "port": port,
+ "baud": float64(9600),
+ "data_bits": float64(7),
+ "parity": "even",
+ "stop_bits": float64(2),
+ })
+ if errResult != nil {
+ t.Fatalf("parseSerialConfig() unexpected error = %v", errResult.ForLLM)
+ }
+
+ wantPort := "/dev/ttyUSB0"
+ if runtime.GOOS == "windows" {
+ wantPort = `\\.\COM3`
+ }
+ if cfg.Port != wantPort || cfg.Baud != 9600 || cfg.DataBits != 7 || cfg.Parity != "even" || cfg.StopBits != 2 {
+ t.Fatalf("parseSerialConfig() = %#v", cfg)
+ }
+}
+
+func TestParseSerialConfigRejectsInvalidParity(t *testing.T) {
+ port := "/dev/ttyUSB0"
+ if runtime.GOOS == "windows" {
+ port = "COM3"
+ }
+
+ _, errResult := parseSerialConfig(map[string]any{
+ "port": port,
+ "parity": "mark",
+ })
+ if errResult == nil {
+ t.Fatal("expected invalid parity to fail")
+ }
+}
+
+func TestParseSerialConfigRejectsUnsupportedUnixBaud(t *testing.T) {
+ if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
+ t.Skip("Unix baud validation only applies on Unix platforms")
+ }
+
+ _, errResult := parseSerialConfig(map[string]any{
+ "port": "/dev/ttyUSB0",
+ "baud": float64(460800),
+ })
+ if errResult == nil {
+ t.Fatal("expected unsupported Unix baud rate to fail")
+ }
+}
+
+func TestParseSerialWritePayloadRejectsFractionalBytes(t *testing.T) {
+ _, errResult := parseSerialWritePayload(map[string]any{
+ "data": []any{65.9},
+ })
+ if errResult == nil {
+ t.Fatal("expected fractional byte value to fail")
+ }
+}
+
+func TestValidateSerialBaud(t *testing.T) {
+ tests := []struct {
+ name string
+ baud int
+ wantErr bool
+ }{
+ {name: "default-supported", baud: 115200},
+ {name: "max-unix-supported", baud: 230400},
+ {name: "too-low", baud: 49, wantErr: true},
+ {name: "too-high", baud: 4000001, wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateSerialBaud(tt.baud)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("validateSerialBaud(%d) error = %v, wantErr %v", tt.baud, err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestSerialReadCanceledBeforeOpen(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ port := "/dev/ttyUSB0"
+ if runtime.GOOS == "windows" {
+ port = "COM3"
+ }
+
+ _, err := serialRead(
+ ctx,
+ serialConfig{Port: port, Baud: 115200, DataBits: 8, Parity: "none", StopBits: 1},
+ 1,
+ time.Second,
+ )
+ if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) {
+ t.Fatalf("serialRead() error = %v, want context canceled", err)
+ }
+}
+
+func TestSerialWriteCanceledBeforeOpen(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ port := "/dev/ttyUSB0"
+ if runtime.GOOS == "windows" {
+ port = "COM3"
+ }
+
+ _, err := serialWrite(
+ ctx,
+ serialConfig{Port: port, Baud: 115200, DataBits: 8, Parity: "none", StopBits: 1},
+ []byte("AT"),
+ time.Second,
+ )
+ if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) {
+ t.Fatalf("serialWrite() error = %v, want context canceled", err)
+ }
+}
+
+func TestParseSerialConfigRejectsUnsafePortPaths(t *testing.T) {
+ tests := []string{
+ "../../../etc/passwd",
+ "/etc/passwd",
+ `C:\temp\device.txt`,
+ `\\.\C:\temp\device.txt`,
+ }
+
+ for _, port := range tests {
+ t.Run(strings.ReplaceAll(port, "/", "_"), func(t *testing.T) {
+ _, errResult := parseSerialConfig(map[string]any{
+ "port": port,
+ })
+ if errResult == nil {
+ t.Fatalf("expected unsafe port %q to be rejected", port)
+ }
+ })
+ }
+}
+
+func TestNormalizeUnixSerialPath(t *testing.T) {
+ tests := []struct {
+ port string
+ want string
+ }{
+ {port: "ttyUSB0", want: "/dev/ttyUSB0"},
+ {port: "/dev/ttyACM0", want: "/dev/ttyACM0"},
+ {port: "/dev/cu.usbserial-0001", want: "/dev/cu.usbserial-0001"},
+ }
+
+ for _, tt := range tests {
+ got, err := normalizeUnixSerialPath(tt.port)
+ if err != nil {
+ t.Fatalf("normalizeUnixSerialPath(%q) unexpected error = %v", tt.port, err)
+ }
+ if got != tt.want {
+ t.Fatalf("normalizeUnixSerialPath(%q) = %q, want %q", tt.port, got, tt.want)
+ }
+ }
+}
+
+func TestNormalizeUnixSerialPathRejectsInvalidNames(t *testing.T) {
+ tests := []string{
+ "",
+ "ttyUSB0/../../passwd",
+ "/dev/../../etc/passwd",
+ "/tmp/ttyUSB0",
+ "ttyUSB",
+ "COM3",
+ }
+
+ for _, port := range tests {
+ t.Run(strings.ReplaceAll(port, "/", "_"), func(t *testing.T) {
+ if _, err := normalizeUnixSerialPath(port); err == nil {
+ t.Fatalf("expected %q to be rejected", port)
+ }
+ })
+ }
+}
+
+func TestNormalizeWindowsSerialPath(t *testing.T) {
+ tests := []struct {
+ port string
+ want string
+ }{
+ {port: "COM3", want: `\\.\COM3`},
+ {port: "com12", want: `\\.\COM12`},
+ {port: `\\.\COM7`, want: `\\.\COM7`},
+ }
+
+ for _, tt := range tests {
+ got, err := normalizeWindowsSerialPath(tt.port)
+ if err != nil {
+ t.Fatalf("normalizeWindowsSerialPath(%q) unexpected error = %v", tt.port, err)
+ }
+ if got != tt.want {
+ t.Fatalf("normalizeWindowsSerialPath(%q) = %q, want %q", tt.port, got, tt.want)
+ }
+ }
+}
+
+func TestNormalizeWindowsSerialPathRejectsInvalidNames(t *testing.T) {
+ tests := []string{
+ "",
+ "COM0",
+ "COM",
+ "/dev/ttyUSB0",
+ `C:\temp\device.txt`,
+ `\\.\C:\temp\device.txt`,
+ `\\server\share\COM3`,
+ }
+
+ for _, port := range tests {
+ t.Run(strings.ReplaceAll(strings.ReplaceAll(port, `\`, "_"), "/", "_"), func(t *testing.T) {
+ if _, err := normalizeWindowsSerialPath(port); err == nil {
+ t.Fatalf("expected %q to be rejected", port)
+ }
+ })
+ }
+}
+
+func TestParseSerialTimeout(t *testing.T) {
+ timeout, errResult := parseSerialTimeout(map[string]any{
+ "timeout_ms": float64(2500),
+ })
+ if errResult != nil {
+ t.Fatalf("parseSerialTimeout() unexpected error = %v", errResult.ForLLM)
+ }
+ if timeout != 2500*time.Millisecond {
+ t.Fatalf("timeout = %v, want 2500ms", timeout)
+ }
+}
+
+func TestParseSerialWritePayloadSupportsText(t *testing.T) {
+ data, errResult := parseSerialWritePayload(map[string]any{
+ "text": "AT\r\n",
+ })
+ if errResult != nil {
+ t.Fatalf("parseSerialWritePayload() unexpected error = %v", errResult.ForLLM)
+ }
+ if string(data) != "AT\r\n" {
+ t.Fatalf("payload = %q, want %q", string(data), "AT\r\n")
+ }
+}
+
+func TestParseSerialWritePayloadRejectsOutOfRangeByte(t *testing.T) {
+ _, errResult := parseSerialWritePayload(map[string]any{
+ "data": []any{float64(256)},
+ })
+ if errResult == nil {
+ t.Fatal("expected payload validation failure")
+ }
+}
diff --git a/pkg/tools/hardware/serial_unix.go b/pkg/tools/hardware/serial_unix.go
new file mode 100644
index 000000000..548b8573b
--- /dev/null
+++ b/pkg/tools/hardware/serial_unix.go
@@ -0,0 +1,286 @@
+//go:build linux || darwin
+
+package hardwaretools
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+var (
+ unixSerialNow = time.Now
+ unixSerialOpenPort = openAndConfigureSerialPort
+ unixSerialClosePort = unix.Close
+ unixSerialPollRead = pollRead
+ unixSerialPollWrite = pollWrite
+)
+
+func serialListPorts() ([]serialPortInfo, error) {
+ patterns := []string{
+ "/dev/ttyS*",
+ "/dev/ttyUSB*",
+ "/dev/ttyACM*",
+ "/dev/ttyAMA*",
+ "/dev/rfcomm*",
+ "/dev/tty.*",
+ "/dev/cu.*",
+ }
+
+ seen := make(map[string]struct{})
+ ports := make([]serialPortInfo, 0)
+ for _, pattern := range patterns {
+ matches, err := filepath.Glob(pattern)
+ if err != nil {
+ return nil, err
+ }
+ for _, match := range matches {
+ if _, ok := seen[match]; ok {
+ continue
+ }
+ info, err := os.Stat(match)
+ if err != nil || info.IsDir() {
+ continue
+ }
+ seen[match] = struct{}{}
+ ports = append(ports, serialPortInfo{
+ Name: filepath.Base(match),
+ Path: match,
+ })
+ }
+ }
+
+ sort.Slice(ports, func(i, j int) bool {
+ return ports[i].Path < ports[j].Path
+ })
+ return ports, nil
+}
+
+func serialRead(ctx context.Context, cfg serialConfig, length int, timeout time.Duration) ([]byte, error) {
+ if err := serialContextErr(ctx); err != nil {
+ return nil, err
+ }
+
+ fd, err := unixSerialOpenPort(cfg)
+ if err != nil {
+ return nil, err
+ }
+ defer unixSerialClosePort(fd)
+
+ buf := make([]byte, length)
+ total := 0
+ deadline := unixSerialNow().Add(timeout)
+
+ for total < length {
+ if err := serialContextErr(ctx); err != nil {
+ return nil, err
+ }
+
+ remaining := deadline.Sub(unixSerialNow())
+ if remaining <= 0 {
+ break
+ }
+
+ n, err := unixSerialPollRead(fd, buf[total:], minSerialPollTimeout(remaining))
+ if err != nil {
+ return nil, err
+ }
+ if n == 0 {
+ continue
+ }
+ total += n
+ }
+
+ return buf[:total], nil
+}
+
+func serialWrite(ctx context.Context, cfg serialConfig, data []byte, timeout time.Duration) (int, error) {
+ if err := serialContextErr(ctx); err != nil {
+ return 0, err
+ }
+
+ fd, err := unixSerialOpenPort(cfg)
+ if err != nil {
+ return 0, err
+ }
+ defer unixSerialClosePort(fd)
+
+ total := 0
+ deadline := unixSerialNow().Add(timeout)
+ for total < len(data) {
+ if err := serialContextErr(ctx); err != nil {
+ return total, err
+ }
+
+ remaining := deadline.Sub(unixSerialNow())
+ if remaining <= 0 {
+ return total, fmt.Errorf("timeout while writing serial data")
+ }
+
+ n, err := unixSerialPollWrite(fd, data[total:], minSerialPollTimeout(remaining))
+ if err != nil {
+ return total, err
+ }
+ if n == 0 {
+ continue
+ }
+ total += n
+ }
+
+ return total, nil
+}
+
+func openAndConfigureSerialPort(cfg serialConfig) (int, error) {
+ fd, err := unix.Open(cfg.Port, unix.O_RDWR|unix.O_NOCTTY|unix.O_NONBLOCK, 0)
+ if err != nil {
+ return -1, err
+ }
+
+ if err := unix.SetNonblock(fd, false); err != nil {
+ unix.Close(fd)
+ return -1, err
+ }
+
+ if err := configureUnixSerialPort(fd, cfg); err != nil {
+ unix.Close(fd)
+ return -1, err
+ }
+
+ return fd, nil
+}
+
+func configureUnixSerialPort(fd int, cfg serialConfig) error {
+ tio, err := serialGetTermios(fd)
+ if err != nil {
+ return err
+ }
+
+ tio.Iflag = 0
+ tio.Oflag = 0
+ tio.Lflag = 0
+ tio.Cflag = unix.CREAD | unix.CLOCAL
+ tio.Cc[unix.VMIN] = 0
+ tio.Cc[unix.VTIME] = 0
+
+ switch cfg.DataBits {
+ case 5:
+ tio.Cflag |= unix.CS5
+ case 6:
+ tio.Cflag |= unix.CS6
+ case 7:
+ tio.Cflag |= unix.CS7
+ default:
+ tio.Cflag |= unix.CS8
+ }
+
+ switch cfg.Parity {
+ case "even":
+ tio.Cflag |= unix.PARENB
+ case "odd":
+ tio.Cflag |= unix.PARENB | unix.PARODD
+ }
+
+ if cfg.StopBits == 2 {
+ tio.Cflag |= unix.CSTOPB
+ }
+
+ speed, err := serialBaudToUnix(cfg.Baud)
+ if err != nil {
+ return err
+ }
+ if err := serialSetSpeed(tio, speed); err != nil {
+ return err
+ }
+
+ return serialSetTermios(fd, tio)
+}
+
+func serialBaudToUnix(baud int) (uint32, error) {
+ switch baud {
+ case 50:
+ return unix.B50, nil
+ case 75:
+ return unix.B75, nil
+ case 110:
+ return unix.B110, nil
+ case 134:
+ return unix.B134, nil
+ case 150:
+ return unix.B150, nil
+ case 200:
+ return unix.B200, nil
+ case 300:
+ return unix.B300, nil
+ case 600:
+ return unix.B600, nil
+ case 1200:
+ return unix.B1200, nil
+ case 1800:
+ return unix.B1800, nil
+ case 2400:
+ return unix.B2400, nil
+ case 4800:
+ return unix.B4800, nil
+ case 9600:
+ return unix.B9600, nil
+ case 19200:
+ return unix.B19200, nil
+ case 38400:
+ return unix.B38400, nil
+ case 57600:
+ return unix.B57600, nil
+ case 115200:
+ return unix.B115200, nil
+ case 230400:
+ return unix.B230400, nil
+ default:
+ return 0, fmt.Errorf("unsupported baud rate on this platform: %d", baud)
+ }
+}
+
+func pollRead(fd int, dst []byte, timeout time.Duration) (int, error) {
+ pfd := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
+ n, err := unix.Poll(pfd, durationToPollTimeout(timeout))
+ if err != nil {
+ return 0, err
+ }
+ if n == 0 {
+ return 0, nil
+ }
+ return unix.Read(fd, dst)
+}
+
+func pollWrite(fd int, src []byte, timeout time.Duration) (int, error) {
+ pfd := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLOUT}}
+ n, err := unix.Poll(pfd, durationToPollTimeout(timeout))
+ if err != nil {
+ return 0, err
+ }
+ if n == 0 {
+ return 0, nil
+ }
+ return unix.Write(fd, src)
+}
+
+func durationToPollTimeout(timeout time.Duration) int {
+ if timeout <= 0 {
+ return 0
+ }
+ ms := int(timeout / time.Millisecond)
+ if ms == 0 {
+ return 1
+ }
+ return ms
+}
+
+func minSerialPollTimeout(timeout time.Duration) time.Duration {
+ if timeout > serialPollInterval {
+ return serialPollInterval
+ }
+ return timeout
+}
diff --git a/pkg/tools/hardware/serial_unix_test.go b/pkg/tools/hardware/serial_unix_test.go
new file mode 100644
index 000000000..fac2efe7f
--- /dev/null
+++ b/pkg/tools/hardware/serial_unix_test.go
@@ -0,0 +1,140 @@
+//go:build linux || darwin
+
+package hardwaretools
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+func stubUnixSerialIO(t *testing.T, now *time.Time) {
+ t.Helper()
+
+ prevNow := unixSerialNow
+ prevOpen := unixSerialOpenPort
+ prevClose := unixSerialClosePort
+ prevPollRead := unixSerialPollRead
+ prevPollWrite := unixSerialPollWrite
+
+ unixSerialNow = func() time.Time {
+ return *now
+ }
+ unixSerialOpenPort = func(cfg serialConfig) (int, error) {
+ return 42, nil
+ }
+ unixSerialClosePort = func(fd int) error {
+ return nil
+ }
+ unixSerialPollRead = prevPollRead
+ unixSerialPollWrite = prevPollWrite
+
+ t.Cleanup(func() {
+ unixSerialNow = prevNow
+ unixSerialOpenPort = prevOpen
+ unixSerialClosePort = prevClose
+ unixSerialPollRead = prevPollRead
+ unixSerialPollWrite = prevPollWrite
+ })
+}
+
+func TestSerialReadWaitsPastEmptyPollsUntilDeadline(t *testing.T) {
+ now := time.Unix(0, 0)
+ stubUnixSerialIO(t, &now)
+
+ pollCalls := 0
+ unixSerialPollRead = func(fd int, dst []byte, timeout time.Duration) (int, error) {
+ pollCalls++
+ if timeout > serialPollInterval {
+ t.Fatalf("poll timeout = %v, want <= %v", timeout, serialPollInterval)
+ }
+ now = now.Add(timeout)
+ if pollCalls < 4 {
+ return 0, nil
+ }
+ return copy(dst, []byte("OK")), nil
+ }
+
+ got, err := serialRead(context.Background(), serialConfig{}, 2, 500*time.Millisecond)
+ if err != nil {
+ t.Fatalf("serialRead() error = %v", err)
+ }
+ if string(got) != "OK" {
+ t.Fatalf("serialRead() = %q, want %q", got, "OK")
+ }
+ if pollCalls != 4 {
+ t.Fatalf("poll calls = %d, want 4", pollCalls)
+ }
+}
+
+func TestSerialReadReturnsPromptlyOnContextCancelBetweenPolls(t *testing.T) {
+ now := time.Unix(0, 0)
+ stubUnixSerialIO(t, &now)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ pollCalls := 0
+ unixSerialPollRead = func(fd int, dst []byte, timeout time.Duration) (int, error) {
+ pollCalls++
+ now = now.Add(timeout)
+ cancel()
+ return 0, nil
+ }
+
+ _, err := serialRead(ctx, serialConfig{}, 1, time.Second)
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("serialRead() error = %v, want context canceled", err)
+ }
+ if pollCalls != 1 {
+ t.Fatalf("poll calls = %d, want 1", pollCalls)
+ }
+}
+
+func TestSerialWriteWaitsPastEmptyPollsUntilReady(t *testing.T) {
+ now := time.Unix(0, 0)
+ stubUnixSerialIO(t, &now)
+
+ pollCalls := 0
+ unixSerialPollWrite = func(fd int, src []byte, timeout time.Duration) (int, error) {
+ pollCalls++
+ if timeout > serialPollInterval {
+ t.Fatalf("poll timeout = %v, want <= %v", timeout, serialPollInterval)
+ }
+ now = now.Add(timeout)
+ switch pollCalls {
+ case 1, 2:
+ return 0, nil
+ default:
+ return 1, nil
+ }
+ }
+
+ written, err := serialWrite(context.Background(), serialConfig{}, []byte("OK"), 500*time.Millisecond)
+ if err != nil {
+ t.Fatalf("serialWrite() error = %v", err)
+ }
+ if written != 2 {
+ t.Fatalf("serialWrite() wrote %d bytes, want 2", written)
+ }
+ if pollCalls != 4 {
+ t.Fatalf("poll calls = %d, want 4", pollCalls)
+ }
+}
+
+func TestSerialWriteTimesOutAfterRepeatedEmptyPolls(t *testing.T) {
+ now := time.Unix(0, 0)
+ stubUnixSerialIO(t, &now)
+
+ unixSerialPollWrite = func(fd int, src []byte, timeout time.Duration) (int, error) {
+ now = now.Add(timeout)
+ return 0, nil
+ }
+
+ written, err := serialWrite(context.Background(), serialConfig{}, []byte("A"), 250*time.Millisecond)
+ if err == nil || err.Error() != "timeout while writing serial data" {
+ t.Fatalf("serialWrite() error = %v, want timeout", err)
+ }
+ if written != 0 {
+ t.Fatalf("serialWrite() wrote %d bytes, want 0", written)
+ }
+}
diff --git a/pkg/tools/hardware/serial_windows.go b/pkg/tools/hardware/serial_windows.go
new file mode 100644
index 000000000..31a215589
--- /dev/null
+++ b/pkg/tools/hardware/serial_windows.go
@@ -0,0 +1,247 @@
+//go:build windows
+
+package hardwaretools
+
+import (
+ "context"
+ "sort"
+ "strings"
+ "time"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+ "golang.org/x/sys/windows/registry"
+)
+
+var (
+ kernel32 = windows.NewLazySystemDLL("kernel32.dll")
+ procGetCommState = kernel32.NewProc("GetCommState")
+ procSetCommState = kernel32.NewProc("SetCommState")
+ procSetCommTimeouts = kernel32.NewProc("SetCommTimeouts")
+ procPurgeComm = kernel32.NewProc("PurgeComm")
+)
+
+const (
+ purgeTxClear = 0x0004
+ purgeRxClear = 0x0008
+
+ dcbFlagBinary = 0x00000001
+ dcbFlagParity = 0x00000002
+ dcbFlagOutxCtsFlow = 0x00000004
+ dcbFlagOutxDsrFlow = 0x00000008
+ dcbFlagDtrControlMask = 0x00000030
+ dcbFlagDsrSensitivity = 0x00000040
+ dcbFlagTXContinueOnXoff = 0x00000080
+ dcbFlagOutX = 0x00000100
+ dcbFlagInX = 0x00000200
+ dcbFlagRtsControlMask = 0x00003000
+)
+
+type dcb struct {
+ DCBlength uint32
+ BaudRate uint32
+ Flags uint32
+ Reserved uint16
+ XonLim uint16
+ XoffLim uint16
+ ByteSize byte
+ Parity byte
+ StopBits byte
+ XonChar byte
+ XoffChar byte
+ ErrorChar byte
+ EofChar byte
+ EvtChar byte
+ wReserved1 uint16
+}
+
+type commTimeouts struct {
+ ReadIntervalTimeout uint32
+ ReadTotalTimeoutMultiplier uint32
+ ReadTotalTimeoutConstant uint32
+ WriteTotalTimeoutMultiplier uint32
+ WriteTotalTimeoutConstant uint32
+}
+
+func serialListPorts() ([]serialPortInfo, error) {
+ key, err := registry.OpenKey(registry.LOCAL_MACHINE, `HARDWARE\DEVICEMAP\SERIALCOMM`, registry.QUERY_VALUE)
+ if err != nil {
+ if err == registry.ErrNotExist {
+ return nil, nil
+ }
+ return nil, err
+ }
+ defer key.Close()
+
+ names, err := key.ReadValueNames(-1)
+ if err != nil {
+ return nil, err
+ }
+
+ ports := make([]serialPortInfo, 0, len(names))
+ seen := make(map[string]struct{})
+ for _, name := range names {
+ value, _, err := key.GetStringValue(name)
+ if err != nil {
+ continue
+ }
+ portName := strings.TrimSpace(value)
+ if portName == "" {
+ continue
+ }
+ normalized := strings.ToUpper(portName)
+ if _, ok := seen[normalized]; ok {
+ continue
+ }
+ seen[normalized] = struct{}{}
+ ports = append(ports, serialPortInfo{
+ Name: normalized,
+ Path: normalized,
+ })
+ }
+
+ sort.Slice(ports, func(i, j int) bool {
+ return ports[i].Path < ports[j].Path
+ })
+ return ports, nil
+}
+
+func serialRead(ctx context.Context, cfg serialConfig, length int, timeout time.Duration) ([]byte, error) {
+ if err := serialContextErr(ctx); err != nil {
+ return nil, err
+ }
+
+ handle, err := openAndConfigureWindowsSerial(cfg, timeout)
+ if err != nil {
+ return nil, err
+ }
+ defer windows.CloseHandle(handle)
+
+ if err := serialContextErr(ctx); err != nil {
+ return nil, err
+ }
+
+ buf := make([]byte, length)
+ var read uint32
+ // Synchronous serial I/O on Windows cannot be interrupted once the syscall starts.
+ // COMMTIMEOUTS bounds how long turn cancellation may take to surface.
+ if err := windows.ReadFile(handle, buf, &read, nil); err != nil {
+ return nil, err
+ }
+ return buf[:read], nil
+}
+
+func serialWrite(ctx context.Context, cfg serialConfig, data []byte, timeout time.Duration) (int, error) {
+ if err := serialContextErr(ctx); err != nil {
+ return 0, err
+ }
+
+ handle, err := openAndConfigureWindowsSerial(cfg, timeout)
+ if err != nil {
+ return 0, err
+ }
+ defer windows.CloseHandle(handle)
+
+ if err := serialContextErr(ctx); err != nil {
+ return 0, err
+ }
+
+ return serialWriteAll(ctx, data, timeout, time.Now, func(chunk []byte) (int, error) {
+ var written uint32
+ // Like ReadFile above, this synchronous WriteFile call relies on COMMTIMEOUTS
+ // rather than context preemption once the syscall is in flight.
+ if err := windows.WriteFile(handle, chunk, &written, nil); err != nil {
+ return int(written), err
+ }
+ return int(written), nil
+ })
+}
+
+func openAndConfigureWindowsSerial(cfg serialConfig, timeout time.Duration) (windows.Handle, error) {
+ handle, err := windows.CreateFile(
+ windows.StringToUTF16Ptr(cfg.Port),
+ windows.GENERIC_READ|windows.GENERIC_WRITE,
+ 0,
+ nil,
+ windows.OPEN_EXISTING,
+ 0,
+ 0,
+ )
+ if err != nil {
+ return 0, err
+ }
+
+ if err := configureWindowsSerialPort(handle, cfg, timeout); err != nil {
+ windows.CloseHandle(handle)
+ return 0, err
+ }
+ return handle, nil
+}
+
+func configureWindowsSerialPort(handle windows.Handle, cfg serialConfig, timeout time.Duration) error {
+ state := dcb{DCBlength: uint32(unsafe.Sizeof(dcb{}))}
+ r1, _, err := procGetCommState.Call(uintptr(handle), uintptr(unsafe.Pointer(&state)))
+ if r1 == 0 {
+ return err
+ }
+
+ state.BaudRate = uint32(cfg.Baud)
+ state.ByteSize = byte(cfg.DataBits)
+ state.Flags = sanitizeWindowsSerialFlags(state.Flags)
+ state.Flags |= dcbFlagBinary
+
+ switch cfg.Parity {
+ case "even":
+ state.Parity = 2
+ state.Flags |= dcbFlagParity
+ case "odd":
+ state.Parity = 1
+ state.Flags |= dcbFlagParity
+ default:
+ state.Parity = 0
+ state.Flags &^= dcbFlagParity
+ }
+
+ switch cfg.StopBits {
+ case 2:
+ state.StopBits = 2
+ default:
+ state.StopBits = 0
+ }
+
+ r1, _, err = procSetCommState.Call(uintptr(handle), uintptr(unsafe.Pointer(&state)))
+ if r1 == 0 {
+ return err
+ }
+
+ timeoutMS := uint32(timeout / time.Millisecond)
+ if timeoutMS == 0 {
+ timeoutMS = 1
+ }
+ timeouts := commTimeouts{
+ ReadIntervalTimeout: timeoutMS,
+ ReadTotalTimeoutConstant: timeoutMS,
+ WriteTotalTimeoutConstant: timeoutMS,
+ ReadTotalTimeoutMultiplier: 0,
+ WriteTotalTimeoutMultiplier: 0,
+ }
+ r1, _, err = procSetCommTimeouts.Call(uintptr(handle), uintptr(unsafe.Pointer(&timeouts)))
+ if r1 == 0 {
+ return err
+ }
+
+ procPurgeComm.Call(uintptr(handle), uintptr(purgeRxClear|purgeTxClear))
+ return nil
+}
+
+func sanitizeWindowsSerialFlags(flags uint32) uint32 {
+ flags &^= dcbFlagOutxCtsFlow |
+ dcbFlagOutxDsrFlow |
+ dcbFlagDtrControlMask |
+ dcbFlagDsrSensitivity |
+ dcbFlagTXContinueOnXoff |
+ dcbFlagOutX |
+ dcbFlagInX |
+ dcbFlagRtsControlMask
+ return flags
+}
diff --git a/pkg/tools/hardware/serial_windows_test.go b/pkg/tools/hardware/serial_windows_test.go
new file mode 100644
index 000000000..ecb0addbd
--- /dev/null
+++ b/pkg/tools/hardware/serial_windows_test.go
@@ -0,0 +1,39 @@
+//go:build windows
+
+package hardwaretools
+
+import "testing"
+
+func TestSanitizeWindowsSerialFlags(t *testing.T) {
+ flags := uint32(
+ dcbFlagBinary |
+ dcbFlagParity |
+ dcbFlagOutxCtsFlow |
+ dcbFlagOutxDsrFlow |
+ dcbFlagDtrControlMask |
+ dcbFlagDsrSensitivity |
+ dcbFlagTXContinueOnXoff |
+ dcbFlagOutX |
+ dcbFlagInX |
+ dcbFlagRtsControlMask,
+ )
+
+ got := sanitizeWindowsSerialFlags(flags)
+
+ if got&dcbFlagBinary == 0 {
+ t.Fatal("sanitizeWindowsSerialFlags() should preserve fBinary")
+ }
+ if got&dcbFlagParity == 0 {
+ t.Fatal("sanitizeWindowsSerialFlags() should preserve fParity")
+ }
+ if got&(dcbFlagOutxCtsFlow|
+ dcbFlagOutxDsrFlow|
+ dcbFlagDtrControlMask|
+ dcbFlagDsrSensitivity|
+ dcbFlagTXContinueOnXoff|
+ dcbFlagOutX|
+ dcbFlagInX|
+ dcbFlagRtsControlMask) != 0 {
+ t.Fatalf("sanitizeWindowsSerialFlags() = %#x, want flow-control bits cleared", got)
+ }
+}
diff --git a/pkg/tools/hardware/serial_write_common_test.go b/pkg/tools/hardware/serial_write_common_test.go
new file mode 100644
index 000000000..398c1fde5
--- /dev/null
+++ b/pkg/tools/hardware/serial_write_common_test.go
@@ -0,0 +1,87 @@
+package hardwaretools
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+func TestSerialWriteAllRetriesPartialWritesUntilComplete(t *testing.T) {
+ now := time.Unix(0, 0)
+ calls := 0
+
+ written, err := serialWriteAll(context.Background(), []byte("PING"), time.Second, func() time.Time {
+ return now
+ }, func(chunk []byte) (int, error) {
+ calls++
+ now = now.Add(100 * time.Millisecond)
+ switch calls {
+ case 1:
+ if string(chunk) != "PING" {
+ t.Fatalf("first chunk = %q, want %q", chunk, "PING")
+ }
+ return 2, nil
+ case 2:
+ if string(chunk) != "NG" {
+ t.Fatalf("second chunk = %q, want %q", chunk, "NG")
+ }
+ return 2, nil
+ default:
+ t.Fatalf("unexpected extra write call %d", calls)
+ return 0, nil
+ }
+ })
+ if err != nil {
+ t.Fatalf("serialWriteAll() error = %v", err)
+ }
+ if written != 4 {
+ t.Fatalf("serialWriteAll() wrote %d bytes, want 4", written)
+ }
+}
+
+func TestSerialWriteAllTimesOutAfterZeroByteWrites(t *testing.T) {
+ now := time.Unix(0, 0)
+ calls := 0
+
+ written, err := serialWriteAll(context.Background(), []byte("A"), 250*time.Millisecond, func() time.Time {
+ return now
+ }, func(chunk []byte) (int, error) {
+ calls++
+ now = now.Add(100 * time.Millisecond)
+ return 0, nil
+ })
+ if err == nil || err.Error() != "timeout while writing serial data" {
+ t.Fatalf("serialWriteAll() error = %v, want timeout", err)
+ }
+ if written != 0 {
+ t.Fatalf("serialWriteAll() wrote %d bytes, want 0", written)
+ }
+ if calls != 3 {
+ t.Fatalf("write calls = %d, want 3", calls)
+ }
+}
+
+func TestSerialWriteAllReturnsContextCancellationAfterRetryBoundary(t *testing.T) {
+ now := time.Unix(0, 0)
+ ctx, cancel := context.WithCancel(context.Background())
+ calls := 0
+
+ written, err := serialWriteAll(ctx, []byte("A"), time.Second, func() time.Time {
+ return now
+ }, func(chunk []byte) (int, error) {
+ calls++
+ now = now.Add(100 * time.Millisecond)
+ cancel()
+ return 0, nil
+ })
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("serialWriteAll() error = %v, want context canceled", err)
+ }
+ if written != 0 {
+ t.Fatalf("serialWriteAll() wrote %d bytes, want 0", written)
+ }
+ if calls != 1 {
+ t.Fatalf("write calls = %d, want 1", calls)
+ }
+}
diff --git a/pkg/tools/hardware/shared.go b/pkg/tools/hardware/shared.go
new file mode 100644
index 000000000..3012f3e6c
--- /dev/null
+++ b/pkg/tools/hardware/shared.go
@@ -0,0 +1,13 @@
+package hardwaretools
+
+import toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
+
+type ToolResult = toolshared.ToolResult
+
+func ErrorResult(message string) *ToolResult {
+ return toolshared.ErrorResult(message)
+}
+
+func SilentResult(forLLM string) *ToolResult {
+ return toolshared.SilentResult(forLLM)
+}
diff --git a/pkg/tools/spi.go b/pkg/tools/hardware/spi.go
similarity index 98%
rename from pkg/tools/spi.go
rename to pkg/tools/hardware/spi.go
index 0ca17e84f..0bc0d8f72 100644
--- a/pkg/tools/spi.go
+++ b/pkg/tools/hardware/spi.go
@@ -1,4 +1,4 @@
-package tools
+package hardwaretools
import (
"context"
@@ -122,8 +122,6 @@ func (t *SPITool) list() *ToolResult {
// Helper function for SPI operations (used by platform-specific implementations)
// parseSPIArgs extracts and validates common SPI parameters
-//
-//nolint:unused // Used by spi_linux.go
func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) {
dev, ok := args["device"].(string)
if !ok || dev == "" {
@@ -160,3 +158,5 @@ func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8,
return dev, speed, mode, bits, ""
}
+
+var _ = parseSPIArgs
diff --git a/pkg/tools/spi_linux.go b/pkg/tools/hardware/spi_linux.go
similarity index 99%
rename from pkg/tools/spi_linux.go
rename to pkg/tools/hardware/spi_linux.go
index 9def73662..8502d6b9e 100644
--- a/pkg/tools/spi_linux.go
+++ b/pkg/tools/hardware/spi_linux.go
@@ -1,4 +1,4 @@
-package tools
+package hardwaretools
import (
"encoding/json"
diff --git a/pkg/tools/spi_other.go b/pkg/tools/hardware/spi_other.go
similarity index 94%
rename from pkg/tools/spi_other.go
rename to pkg/tools/hardware/spi_other.go
index 5d078ac3f..89fc99e67 100644
--- a/pkg/tools/spi_other.go
+++ b/pkg/tools/hardware/spi_other.go
@@ -1,6 +1,6 @@
//go:build !linux
-package tools
+package hardwaretools
// transfer is a stub for non-Linux platforms.
func (t *SPITool) transfer(args map[string]any) *ToolResult {
diff --git a/pkg/tools/hardware_facade.go b/pkg/tools/hardware_facade.go
new file mode 100644
index 000000000..b505c5a48
--- /dev/null
+++ b/pkg/tools/hardware_facade.go
@@ -0,0 +1,21 @@
+package tools
+
+import hardwaretools "github.com/sipeed/picoclaw/pkg/tools/hardware"
+
+type (
+ I2CTool = hardwaretools.I2CTool
+ SerialTool = hardwaretools.SerialTool
+ SPITool = hardwaretools.SPITool
+)
+
+func NewI2CTool() *I2CTool {
+ return hardwaretools.NewI2CTool()
+}
+
+func NewSPITool() *SPITool {
+ return hardwaretools.NewSPITool()
+}
+
+func NewSerialTool() *SerialTool {
+ return hardwaretools.NewSerialTool()
+}
diff --git a/pkg/tools/identifier_compat.go b/pkg/tools/identifier_compat.go
new file mode 100644
index 000000000..c5a6d9cf3
--- /dev/null
+++ b/pkg/tools/identifier_compat.go
@@ -0,0 +1,48 @@
+package tools
+
+import "strings"
+
+func sanitizeIdentifierComponent(s string) string {
+ const maxLen = 64
+
+ s = strings.ToLower(s)
+ var b strings.Builder
+ b.Grow(len(s))
+
+ prevUnderscore := false
+ for _, r := range s {
+ isAllowed := (r >= 'a' && r <= 'z') ||
+ (r >= '0' && r <= '9') ||
+ r == '_' || r == '-'
+
+ if !isAllowed {
+ if !prevUnderscore {
+ b.WriteRune('_')
+ prevUnderscore = true
+ }
+ continue
+ }
+
+ if r == '_' {
+ if prevUnderscore {
+ continue
+ }
+ prevUnderscore = true
+ } else {
+ prevUnderscore = false
+ }
+
+ b.WriteRune(r)
+ }
+
+ result := strings.Trim(b.String(), "_")
+ if result == "" {
+ result = "unnamed"
+ }
+
+ if len(result) > maxLen {
+ result = result[:maxLen]
+ }
+
+ return result
+}
diff --git a/pkg/tools/integration/helpers.go b/pkg/tools/integration/helpers.go
new file mode 100644
index 000000000..b34fbc6cd
--- /dev/null
+++ b/pkg/tools/integration/helpers.go
@@ -0,0 +1,134 @@
+package integrationtools
+
+import (
+ "fmt"
+ "math"
+ "mime"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "unicode"
+)
+
+var (
+ inlineMarkdownDataURLRe = regexp.MustCompile(`!\[[^\]]*\]\((data:[^)]+)\)`)
+ inlineRawDataURLRe = regexp.MustCompile(`data:[^;\s]+;base64,[A-Za-z0-9+/=\r\n]+`)
+)
+
+const (
+ largeBase64OmittedMessage = "[Tool returned a large base64-like payload; omitted from model context.]"
+ inlineMediaOmittedMessage = "[Tool returned inline media content; omitted from model context.]"
+)
+
+func sanitizeToolLLMContent(text string) string {
+ trimmed := strings.TrimSpace(text)
+ if trimmed == "" {
+ return text
+ }
+ if inlineMarkdownDataURLRe.MatchString(trimmed) || inlineRawDataURLRe.MatchString(trimmed) {
+ cleaned := inlineMarkdownDataURLRe.ReplaceAllString(trimmed, "")
+ cleaned = inlineRawDataURLRe.ReplaceAllString(cleaned, "")
+ cleaned = strings.TrimSpace(cleaned)
+ if cleaned == "" {
+ return inlineMediaOmittedMessage
+ }
+ return cleaned + "\n" + inlineMediaOmittedMessage
+ }
+ if looksLikeLargeBase64Payload(trimmed) {
+ return largeBase64OmittedMessage
+ }
+ return text
+}
+
+func looksLikeLargeBase64Payload(text string) bool {
+ trimmed := strings.TrimSpace(text)
+ if len(trimmed) < 1024 {
+ return false
+ }
+
+ nonSpace := 0
+ base64Like := 0
+ spaceCount := 0
+
+ for _, r := range trimmed {
+ if unicode.IsSpace(r) {
+ spaceCount++
+ continue
+ }
+ nonSpace++
+ if (r >= 'A' && r <= 'Z') ||
+ (r >= 'a' && r <= 'z') ||
+ (r >= '0' && r <= '9') ||
+ r == '+' || r == '/' || r == '=' {
+ base64Like++
+ }
+ }
+
+ if nonSpace == 0 {
+ return false
+ }
+
+ ratio := float64(base64Like) / float64(nonSpace)
+ return ratio >= 0.97 && spaceCount <= len(trimmed)/128
+}
+
+func extensionForMIMEType(mimeType string) string {
+ if mimeType == "" {
+ return ".bin"
+ }
+ if exts, err := mime.ExtensionsByType(mimeType); err == nil && len(exts) > 0 {
+ return exts[0]
+ }
+
+ switch strings.ToLower(mimeType) {
+ case "image/jpeg":
+ return ".jpg"
+ case "image/png":
+ return ".png"
+ case "image/gif":
+ return ".gif"
+ case "image/webp":
+ return ".webp"
+ case "audio/wav", "audio/x-wav":
+ return ".wav"
+ case "audio/mpeg":
+ return ".mp3"
+ case "audio/ogg":
+ return ".ogg"
+ case "video/mp4":
+ return ".mp4"
+ default:
+ return filepath.Ext(mimeType)
+ }
+}
+
+func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) {
+ raw, exists := args[key]
+ if !exists {
+ return defaultVal, nil
+ }
+
+ switch v := raw.(type) {
+ case float64:
+ if v != math.Trunc(v) {
+ return 0, fmt.Errorf("%s must be an integer, got float %v", key, v)
+ }
+ if v > math.MaxInt64 || v < math.MinInt64 {
+ return 0, fmt.Errorf("%s value %v overflows int64", key, v)
+ }
+ return int64(v), nil
+ case int:
+ return int64(v), nil
+ case int64:
+ return v, nil
+ case string:
+ parsed, err := strconv.ParseInt(v, 10, 64)
+ if err != nil {
+ return 0, fmt.Errorf("invalid integer format for %s parameter: %w", key, err)
+ }
+ return parsed, nil
+ default:
+ return 0, fmt.Errorf("unsupported type %T for %s parameter", raw, key)
+ }
+}
diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/integration/mcp_tool.go
similarity index 67%
rename from pkg/tools/mcp_tool.go
rename to pkg/tools/integration/mcp_tool.go
index 5bffb4e89..8cfc1de5e 100644
--- a/pkg/tools/mcp_tool.go
+++ b/pkg/tools/integration/mcp_tool.go
@@ -1,4 +1,4 @@
-package tools
+package integrationtools
import (
"context"
@@ -6,12 +6,17 @@ import (
"fmt"
"hash/fnv"
"os"
+ "path/filepath"
"strings"
"time"
+ "unicode/utf8"
"github.com/modelcontextprotocol/go-sdk/mcp"
+ runtimeevents "github.com/sipeed/picoclaw/pkg/events"
+ "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
+ toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
)
// MCPManager defines the interface for MCP manager operations
@@ -26,18 +31,31 @@ type MCPManager interface {
// MCPTool wraps an MCP tool to implement the Tool interface
type MCPTool struct {
- manager MCPManager
- serverName string
- tool *mcp.Tool
- mediaStore media.MediaStore
+ manager MCPManager
+ serverName string
+ tool *mcp.Tool
+ mediaStore media.MediaStore
+ workspace string
+ maxInlineTextRunes int
+ runtimeEvents runtimeevents.Bus
+}
+
+// MCPToolCallPayload describes MCP tool execution runtime events.
+type MCPToolCallPayload struct {
+ Server string `json:"server"`
+ Tool string `json:"tool"`
+ DurationMS int64 `json:"duration_ms,omitempty"`
+ IsError bool `json:"is_error,omitempty"`
+ Error string `json:"error,omitempty"`
}
// NewMCPTool creates a new MCP tool wrapper
func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool {
return &MCPTool{
- manager: manager,
- serverName: serverName,
- tool: tool,
+ manager: manager,
+ serverName: serverName,
+ tool: tool,
+ maxInlineTextRunes: maxMCPInlineTextRunes,
}
}
@@ -45,6 +63,23 @@ func (t *MCPTool) SetMediaStore(store media.MediaStore) {
t.mediaStore = store
}
+func (t *MCPTool) SetWorkspace(workspace string) {
+ t.workspace = strings.TrimSpace(workspace)
+}
+
+func (t *MCPTool) SetMaxInlineTextRunes(limit int) {
+ if limit > 0 {
+ t.maxInlineTextRunes = limit
+ }
+}
+
+// SetEventPublisher injects the runtime event bus used for MCP tool observations.
+func (t *MCPTool) SetEventPublisher(eventBus runtimeevents.Bus) {
+ t.runtimeEvents = eventBus
+}
+
+const maxMCPInlineTextRunes = 16 * 1024
+
// sanitizeIdentifierComponent normalizes a string so it can be safely used
// as part of a tool/function identifier for downstream providers.
// It:
@@ -143,6 +178,14 @@ func (t *MCPTool) Description() string {
return fmt.Sprintf("[MCP:%s] %s", t.serverName, desc)
}
+func (t *MCPTool) PromptMetadata() toolshared.PromptMetadata {
+ return toolshared.PromptMetadata{
+ Layer: toolshared.ToolPromptLayerCapability,
+ Slot: toolshared.ToolPromptSlotMCP,
+ Source: "mcp:" + sanitizeIdentifierComponent(t.serverName),
+ }
+}
+
// Parameters returns the tool parameters schema
func (t *MCPTool) Parameters() map[string]any {
// The InputSchema is already a JSON Schema object
@@ -210,26 +253,88 @@ func (t *MCPTool) Parameters() map[string]any {
// Execute executes the MCP tool
func (t *MCPTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ startedAt := time.Now()
+ t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallStart, startedAt, false, "")
+
result, err := t.manager.CallTool(ctx, t.serverName, t.tool.Name, args)
if err != nil {
+ t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, err.Error())
return ErrorResult(fmt.Sprintf("MCP tool execution failed: %v", err)).WithError(err)
}
if result == nil {
nilErr := fmt.Errorf("MCP tool returned nil result without error")
+ t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, nilErr.Error())
return ErrorResult("MCP tool execution failed: nil result").WithError(nilErr)
}
// Handle error result from server
if result.IsError {
errMsg := extractContentText(result.Content)
+ t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, errMsg)
return ErrorResult(fmt.Sprintf("MCP tool returned error: %s", errMsg)).
WithError(fmt.Errorf("MCP tool error: %s", errMsg))
}
+ t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, false, "")
return t.normalizeResultContent(ctx, result.Content)
}
+func (t *MCPTool) publishRuntimeEvent(
+ ctx context.Context,
+ kind runtimeevents.Kind,
+ startedAt time.Time,
+ isError bool,
+ errMsg string,
+) {
+ if t == nil || t.runtimeEvents == nil {
+ return
+ }
+
+ scope := runtimeevents.Scope{
+ AgentID: toolshared.ToolAgentID(ctx),
+ SessionKey: toolshared.ToolSessionKey(ctx),
+ Channel: toolshared.ToolChannel(ctx),
+ ChatID: toolshared.ToolChatID(ctx),
+ MessageID: toolshared.ToolMessageID(ctx),
+ }
+ payload := MCPToolCallPayload{
+ Server: t.serverName,
+ Tool: t.tool.Name,
+ DurationMS: time.Since(startedAt).Milliseconds(),
+ IsError: isError,
+ Error: errMsg,
+ }
+ severity := runtimeevents.SeverityInfo
+ if isError {
+ severity = runtimeevents.SeverityError
+ }
+
+ t.runtimeEvents.PublishNonBlocking(runtimeevents.Event{
+ Kind: kind,
+ Source: runtimeevents.Source{Component: "mcp", Name: t.serverName},
+ Scope: scope,
+ Severity: severity,
+ Payload: payload,
+ Attrs: mcpToolCallEventAttrs(payload),
+ })
+}
+
+func mcpToolCallEventAttrs(payload MCPToolCallPayload) map[string]any {
+ attrs := map[string]any{
+ "server": payload.Server,
+ "tool": payload.Tool,
+ "duration_ms": payload.DurationMS,
+ }
+ if payload.IsError {
+ attrs["is_error"] = payload.IsError
+ }
+ if payload.Error != "" {
+ attrs["error"] = payload.Error
+ }
+ return attrs
+}
+
// extractContentText extracts text from MCP content array
func extractContentText(content []mcp.Content) string {
var parts []string
@@ -255,14 +360,19 @@ func extractContentText(content []mcp.Content) string {
func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Content) *ToolResult {
llmParts := make([]string, 0, len(content))
+ rawTextParts := make([]string, 0, len(content))
mediaRefs := make([]string, 0, len(content))
for _, c := range content {
switch v := c.(type) {
case *mcp.TextContent:
- text := strings.TrimSpace(sanitizeToolLLMContent(v.Text))
- if text != "" {
- llmParts = append(llmParts, text)
+ rawText := strings.TrimSpace(v.Text)
+ if rawText != "" {
+ rawTextParts = append(rawTextParts, rawText)
+ }
+ safeText := strings.TrimSpace(sanitizeToolLLMContent(v.Text))
+ if safeText != "" {
+ llmParts = append(llmParts, safeText)
}
case *mcp.ImageContent:
ref, note := t.storeBinaryContent(
@@ -295,10 +405,13 @@ func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Cont
case *mcp.ResourceLink:
llmParts = append(llmParts, summarizeResourceLink(v))
case *mcp.EmbeddedResource:
- ref, note := t.storeEmbeddedResource(ctx, v)
+ ref, note, rawText := t.storeEmbeddedResource(ctx, v)
if ref != "" {
mediaRefs = append(mediaRefs, ref)
}
+ if rawText != "" {
+ rawTextParts = append(rawTextParts, rawText)
+ }
if note != "" {
llmParts = append(llmParts, note)
}
@@ -307,34 +420,105 @@ func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Cont
}
}
+ forLLM := strings.Join(compactStrings(llmParts), "\n")
+ rawText := strings.Join(compactStrings(rawTextParts), "\n")
+ if artifactResult := t.persistLargeTextArtifact(rawText); artifactResult != nil {
+ artifactResult.Media = mediaRefs
+ return artifactResult
+ }
+
result := &ToolResult{
- ForLLM: strings.Join(compactStrings(llmParts), "\n"),
+ ForLLM: forLLM,
Media: mediaRefs,
}
return result
}
-func (t *MCPTool) storeEmbeddedResource(ctx context.Context, content *mcp.EmbeddedResource) (string, string) {
+func (t *MCPTool) persistLargeTextArtifact(text string) *ToolResult {
+ text = strings.TrimSpace(text)
+ limit := t.maxInlineTextRunes
+ if limit <= 0 {
+ limit = maxMCPInlineTextRunes
+ }
+ size := utf8.RuneCountInString(text)
+ if text == "" || size <= limit || t.workspace == "" {
+ return nil
+ }
+
+ dir := filepath.Join(t.workspace, ".artifacts", "mcp")
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return t.largeTextArtifactFallback(text, err)
+ }
+ // TODO: Add lifecycle cleanup/retention for MCP artifact files.
+
+ pattern := fmt.Sprintf(
+ "%s_%s_*.txt",
+ sanitizeIdentifierComponent(t.serverName),
+ sanitizeIdentifierComponent(t.tool.Name),
+ )
+ tmpFile, err := os.CreateTemp(dir, pattern)
+ if err != nil {
+ return t.largeTextArtifactFallback(text, err)
+ }
+ path := tmpFile.Name()
+ if _, err = tmpFile.WriteString(text); err != nil {
+ _ = tmpFile.Close()
+ _ = os.Remove(path)
+ return t.largeTextArtifactFallback(text, err)
+ }
+ if err = tmpFile.Close(); err != nil {
+ _ = os.Remove(path)
+ return t.largeTextArtifactFallback(text, err)
+ }
+
+ return &ToolResult{
+ ForLLM: fmt.Sprintf(
+ "[MCP returned a large text result (%d chars); omitted from model context and saved as a local artifact.]",
+ size,
+ ),
+ ArtifactTags: []string{"[file:" + path + "]"},
+ }
+}
+
+func (t *MCPTool) largeTextArtifactFallback(text string, err error) *ToolResult {
+ size := utf8.RuneCountInString(text)
+ logger.WarnCF("tool", "Failed to persist large MCP text artifact", map[string]any{
+ "server": t.serverName,
+ "tool": t.tool.Name,
+ "chars": size,
+ "error": err.Error(),
+ })
+ return &ToolResult{
+ ForLLM: fmt.Sprintf(
+ "[MCP returned a large text result (%d chars); omitted from model context because artifact persistence failed.]",
+ size,
+ ),
+ }
+}
+
+func (t *MCPTool) storeEmbeddedResource(ctx context.Context, content *mcp.EmbeddedResource) (string, string, string) {
if content == nil || content.Resource == nil {
- return "", "[MCP returned an embedded resource without data.]"
+ return "", "[MCP returned an embedded resource without data.]", ""
}
resource := content.Resource
if len(resource.Blob) > 0 {
- return t.storeBinaryContent(
+ ref, note := t.storeBinaryContent(
ctx,
"resource",
normalizedMIMEType(resource.MIMEType),
resource.Blob,
content.Annotations,
)
+ return ref, note, ""
}
- if strings.TrimSpace(resource.Text) != "" {
- return "", sanitizeToolLLMContent(resource.Text)
+ rawText := strings.TrimSpace(resource.Text)
+ if rawText != "" {
+ return "", sanitizeToolLLMContent(resource.Text), rawText
}
- return "", summarizeEmbeddedResource(content)
+ return "", summarizeEmbeddedResource(content), ""
}
func (t *MCPTool) storeBinaryContent(
diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/integration/mcp_tool_test.go
similarity index 65%
rename from pkg/tools/mcp_tool_test.go
rename to pkg/tools/integration/mcp_tool_test.go
index 8bbac3bc7..7c961e1e1 100644
--- a/pkg/tools/mcp_tool_test.go
+++ b/pkg/tools/integration/mcp_tool_test.go
@@ -1,4 +1,4 @@
-package tools
+package integrationtools
import (
"context"
@@ -7,10 +7,13 @@ import (
"path/filepath"
"strings"
"testing"
+ "time"
"github.com/modelcontextprotocol/go-sdk/mcp"
+ runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/media"
+ toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
)
// MockMCPManager is a mock implementation of MCPManager interface for testing
@@ -104,6 +107,22 @@ func TestMCPTool_Name(t *testing.T) {
}
}
+func TestMCPTool_PromptMetadata(t *testing.T) {
+ manager := &MockMCPManager{}
+ tool := NewMCPTool(manager, "GitHub Server", &mcp.Tool{Name: "create_issue"})
+
+ metadata := tool.PromptMetadata()
+ if metadata.Layer != toolshared.ToolPromptLayerCapability {
+ t.Fatalf("metadata.Layer = %q, want %q", metadata.Layer, toolshared.ToolPromptLayerCapability)
+ }
+ if metadata.Slot != toolshared.ToolPromptSlotMCP {
+ t.Fatalf("metadata.Slot = %q, want %q", metadata.Slot, toolshared.ToolPromptSlotMCP)
+ }
+ if metadata.Source != "mcp:github_server" {
+ t.Fatalf("metadata.Source = %q, want mcp:github_server", metadata.Source)
+ }
+}
+
// TestMCPTool_Description verifies tool description generation
func TestMCPTool_Description(t *testing.T) {
tests := []struct {
@@ -282,6 +301,77 @@ func TestMCPTool_Execute_Success(t *testing.T) {
}
}
+func TestMCPTool_Execute_PublishesRuntimeEvents(t *testing.T) {
+ eventBus := runtimeevents.NewBus()
+ defer func() {
+ if err := eventBus.Close(); err != nil {
+ t.Errorf("event bus close failed: %v", err)
+ }
+ }()
+
+ _, eventsCh, err := eventBus.Channel().OfKind(
+ runtimeevents.KindMCPToolCallStart,
+ runtimeevents.KindMCPToolCallEnd,
+ ).SubscribeChan(t.Context(), runtimeevents.SubscribeOptions{Name: "mcp-tool-events", Buffer: 2})
+ if err != nil {
+ t.Fatalf("SubscribeChan failed: %v", err)
+ }
+
+ manager := &MockMCPManager{}
+ mcpTool := NewMCPTool(manager, "github", &mcp.Tool{Name: "search_repos"})
+ mcpTool.SetEventPublisher(eventBus)
+
+ ctx := toolshared.WithToolContext(context.Background(), "telegram", "chat-1")
+ ctx = toolshared.WithToolMessageContext(ctx, "msg-1", "")
+ ctx = toolshared.WithToolSessionContext(ctx, "main", "session-1", nil)
+ result := mcpTool.Execute(ctx, map[string]any{"query": "picoclaw"})
+ if result == nil || result.IsError {
+ t.Fatalf("Execute result = %+v", result)
+ }
+
+ started := receiveMCPToolRuntimeEvent(t, eventsCh)
+ if started.Kind != runtimeevents.KindMCPToolCallStart ||
+ started.Scope.AgentID != "main" ||
+ started.Scope.SessionKey != "session-1" ||
+ started.Scope.Channel != "telegram" ||
+ started.Scope.ChatID != "chat-1" ||
+ started.Scope.MessageID != "msg-1" {
+ t.Fatalf("started event = %+v", started)
+ }
+
+ ended := receiveMCPToolRuntimeEvent(t, eventsCh)
+ if ended.Kind != runtimeevents.KindMCPToolCallEnd || ended.Severity != runtimeevents.SeverityInfo {
+ t.Fatalf("ended event = %+v", ended)
+ }
+ payload, ok := ended.Payload.(MCPToolCallPayload)
+ if !ok {
+ t.Fatalf("ended payload = %T, want MCPToolCallPayload", ended.Payload)
+ }
+ if payload.Server != "github" || payload.Tool != "search_repos" || payload.IsError {
+ t.Fatalf("ended payload = %+v", payload)
+ }
+ if ended.Attrs["server"] != "github" ||
+ ended.Attrs["tool"] != "search_repos" ||
+ ended.Attrs["duration_ms"] == nil {
+ t.Fatalf("ended attrs = %#v", ended.Attrs)
+ }
+}
+
+func receiveMCPToolRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event {
+ t.Helper()
+
+ select {
+ case evt, ok := <-ch:
+ if !ok {
+ t.Fatal("runtime event channel closed before expected event")
+ }
+ return evt
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for runtime event")
+ return runtimeevents.Event{}
+ }
+}
+
// TestMCPTool_Execute_ManagerError tests execution when manager returns error
func TestMCPTool_Execute_ManagerError(t *testing.T) {
manager := &MockMCPManager{
@@ -634,3 +724,177 @@ func TestMCPTool_Execute_LargeBase64TextIsOmittedFromContext(t *testing.T) {
t.Fatalf("expected sanitized large base64 note, got %q", result.ForLLM)
}
}
+
+func TestMCPTool_Execute_LargeBase64TextArtifactPreservesRawPayload(t *testing.T) {
+ workspace := t.TempDir()
+ largeBase64 := strings.Repeat("QUJD", 400)
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: largeBase64},
+ },
+ }, nil
+ },
+ }
+
+ mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"})
+ mcpTool.SetWorkspace(workspace)
+ mcpTool.SetMaxInlineTextRunes(32)
+
+ result := mcpTool.Execute(context.Background(), nil)
+
+ if !strings.Contains(result.ForLLM, "saved as a local artifact") {
+ t.Fatalf("expected artifact note, got %q", result.ForLLM)
+ }
+ if result.ForLLM == largeBase64OmittedMessage {
+ t.Fatalf("expected artifact note instead of sanitized base64 placeholder")
+ }
+ if len(result.ArtifactTags) != 1 {
+ t.Fatalf("expected 1 artifact tag, got %d", len(result.ArtifactTags))
+ }
+ tag := result.ArtifactTags[0]
+ const prefix = "[file:"
+ if !strings.HasPrefix(tag, prefix) || !strings.HasSuffix(tag, "]") {
+ t.Fatalf("expected file artifact tag, got %q", tag)
+ }
+ path := strings.TrimSuffix(strings.TrimPrefix(tag, prefix), "]")
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("expected artifact file to be readable: %v", err)
+ }
+ if string(data) != largeBase64 {
+ t.Fatalf("expected artifact file contents to preserve raw MCP payload")
+ }
+}
+
+func TestMCPTool_Execute_LargeTextStoredAsArtifact(t *testing.T) {
+ workspace := t.TempDir()
+ largeText := strings.Repeat("This is a large MCP text payload.\n", 800)
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: largeText},
+ },
+ }, nil
+ },
+ }
+
+ mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"})
+ mcpTool.SetWorkspace(workspace)
+
+ result := mcpTool.Execute(context.Background(), nil)
+
+ if strings.Contains(result.ForLLM, "This is a large MCP text payload") {
+ t.Fatalf("expected large MCP text to be omitted from ForLLM, got %q", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "saved as a local artifact") {
+ t.Fatalf("expected artifact note, got %q", result.ForLLM)
+ }
+ if len(result.ArtifactTags) != 1 {
+ t.Fatalf("expected 1 artifact tag, got %d", len(result.ArtifactTags))
+ }
+ tag := result.ArtifactTags[0]
+ const prefix = "[file:"
+ if !strings.HasPrefix(tag, prefix) || !strings.HasSuffix(tag, "]") {
+ t.Fatalf("expected file artifact tag, got %q", tag)
+ }
+ path := strings.TrimSuffix(strings.TrimPrefix(tag, prefix), "]")
+ if !strings.HasPrefix(path, workspace) {
+ t.Fatalf("expected artifact inside workspace, got %q", path)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("expected artifact file to be readable: %v", err)
+ }
+ if string(data) != strings.TrimSpace(largeText) {
+ t.Fatalf("expected artifact file contents to match source text")
+ }
+}
+
+func TestMCPTool_Execute_CustomInlineTextThreshold(t *testing.T) {
+ workspace := t.TempDir()
+ text := strings.Repeat("small custom threshold text\n", 20)
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: text},
+ },
+ }, nil
+ },
+ }
+
+ mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"})
+ mcpTool.SetWorkspace(workspace)
+ mcpTool.SetMaxInlineTextRunes(32)
+
+ result := mcpTool.Execute(context.Background(), nil)
+
+ if len(result.ArtifactTags) != 1 {
+ t.Fatalf("expected custom threshold to persist artifact, got %+v", result)
+ }
+ if strings.Contains(result.ForLLM, "small custom threshold text") {
+ t.Fatalf("expected text to be omitted from ForLLM, got %q", result.ForLLM)
+ }
+}
+
+func TestMCPTool_Execute_LargeTextArtifactFailureStillOmitsContext(t *testing.T) {
+ workspaceRoot := t.TempDir()
+ workspaceFile := filepath.Join(workspaceRoot, "not-a-directory")
+ if err := os.WriteFile(workspaceFile, []byte("x"), 0o600); err != nil {
+ t.Fatalf("failed to create workspace file: %v", err)
+ }
+
+ largeText := strings.Repeat("This is a large MCP text payload.\n", 800)
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: largeText},
+ },
+ }, nil
+ },
+ }
+
+ mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"})
+ mcpTool.SetWorkspace(workspaceFile)
+
+ result := mcpTool.Execute(context.Background(), nil)
+
+ if strings.Contains(result.ForLLM, "This is a large MCP text payload") {
+ t.Fatalf("expected large MCP text to be omitted from ForLLM, got %q", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "artifact persistence failed") {
+ t.Fatalf("expected persistence failure note, got %q", result.ForLLM)
+ }
+ if len(result.ArtifactTags) != 0 {
+ t.Fatalf("expected no artifact tags on persistence failure, got %+v", result.ArtifactTags)
+ }
+}
+
+func TestMCPTool_Execute_WhitespaceWorkspaceDisablesArtifactPersistence(t *testing.T) {
+ largeText := strings.Repeat("This is a large MCP text payload.\n", 800)
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: largeText},
+ },
+ }, nil
+ },
+ }
+
+ mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"})
+ mcpTool.SetWorkspace(" \n\t ")
+
+ result := mcpTool.Execute(context.Background(), nil)
+
+ if len(result.ArtifactTags) != 0 {
+ t.Fatalf("expected no artifact tags for whitespace workspace, got %+v", result.ArtifactTags)
+ }
+ if !strings.Contains(result.ForLLM, "This is a large MCP text payload") {
+ t.Fatalf("expected large text to remain inline when workspace is blank, got %q", result.ForLLM)
+ }
+}
diff --git a/pkg/tools/integration/message.go b/pkg/tools/integration/message.go
new file mode 100644
index 000000000..98d87bcb3
--- /dev/null
+++ b/pkg/tools/integration/message.go
@@ -0,0 +1,143 @@
+package integrationtools
+
+import (
+ "context"
+ "fmt"
+ "sync"
+)
+
+type SendCallbackWithContext func(ctx context.Context, channel, chatID, content, replyToMessageID string) error
+
+// sentTarget records the channel+chatID that the message tool sent to.
+type sentTarget struct {
+ Channel string
+ ChatID string
+}
+
+type MessageTool struct {
+ sendCallback SendCallbackWithContext
+ mu sync.Mutex
+ // sentTargets tracks targets sent to in the current round, keyed by session key
+ // to support parallel turns for different sessions.
+ sentTargets map[string][]sentTarget
+}
+
+func NewMessageTool() *MessageTool {
+ return &MessageTool{
+ sentTargets: make(map[string][]sentTarget),
+ }
+}
+
+func (t *MessageTool) Name() string {
+ return "message"
+}
+
+func (t *MessageTool) Description() string {
+ return "Send a message to user on a chat channel. Use this when you want to communicate something."
+}
+
+func (t *MessageTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "content": map[string]any{
+ "type": "string",
+ "description": "The message content to send",
+ },
+ "channel": map[string]any{
+ "type": "string",
+ "description": "Optional: target channel (telegram, whatsapp, etc.)",
+ },
+ "chat_id": map[string]any{
+ "type": "string",
+ "description": "Optional: target chat/user ID",
+ },
+ "reply_to_message_id": map[string]any{
+ "type": "string",
+ "description": "Optional: reply target message ID for channels that support threaded replies",
+ },
+ },
+ "required": []string{"content"},
+ }
+}
+
+// ResetSentInRound resets the per-round send tracker for the given session key.
+// Called by the agent loop at the start of each inbound message processing round.
+func (t *MessageTool) ResetSentInRound(sessionKey string) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ // Delete the key entirely to prevent unbounded map growth over time
+ // with many unique sessions. Truncating the slice keeps the key alive.
+ delete(t.sentTargets, sessionKey)
+}
+
+// HasSentInRound returns true if the message tool sent a message during the current round.
+func (t *MessageTool) HasSentInRound(sessionKey string) bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return len(t.sentTargets[sessionKey]) > 0
+}
+
+// HasSentTo returns true if the message tool sent to the specific channel+chatID
+// during the current round. Used by PublishResponseIfNeeded to avoid suppressing
+// the final response when the message tool only sent to a different conversation.
+func (t *MessageTool) HasSentTo(sessionKey, channel, chatID string) bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ for _, st := range t.sentTargets[sessionKey] {
+ if st.Channel == channel && st.ChatID == chatID {
+ return true
+ }
+ }
+ return false
+}
+
+func (t *MessageTool) SetSendCallback(callback SendCallbackWithContext) {
+ t.sendCallback = callback
+}
+
+func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ content, ok := args["content"].(string)
+ if !ok {
+ return &ToolResult{ForLLM: "content is required", IsError: true}
+ }
+
+ channel, _ := args["channel"].(string)
+ chatID, _ := args["chat_id"].(string)
+ replyToMessageID, _ := args["reply_to_message_id"].(string)
+
+ if channel == "" {
+ channel = ToolChannel(ctx)
+ }
+ if chatID == "" {
+ chatID = ToolChatID(ctx)
+ }
+
+ if channel == "" || chatID == "" {
+ return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true}
+ }
+
+ if t.sendCallback == nil {
+ return &ToolResult{ForLLM: "Message sending not configured", IsError: true}
+ }
+
+ if err := t.sendCallback(ctx, channel, chatID, content, replyToMessageID); err != nil {
+ return &ToolResult{
+ ForLLM: fmt.Sprintf("sending message: %v", err),
+ IsError: true,
+ Err: err,
+ }
+ }
+
+ sessionKey := ToolSessionKey(ctx)
+ t.mu.Lock()
+ t.sentTargets[sessionKey] = append(t.sentTargets[sessionKey], sentTarget{Channel: channel, ChatID: chatID})
+ t.mu.Unlock()
+
+ // Silent: user already received the message directly
+ return &ToolResult{
+ ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),
+ Silent: true,
+ }
+}
diff --git a/pkg/tools/message_test.go b/pkg/tools/integration/message_test.go
similarity index 68%
rename from pkg/tools/message_test.go
rename to pkg/tools/integration/message_test.go
index 05630972e..c7b7d2b6e 100644
--- a/pkg/tools/message_test.go
+++ b/pkg/tools/integration/message_test.go
@@ -1,19 +1,25 @@
-package tools
+package integrationtools
import (
"context"
"errors"
"testing"
+
+ "github.com/sipeed/picoclaw/pkg/session"
)
func TestMessageTool_Execute_Success(t *testing.T) {
tool := NewMessageTool()
var sentChannel, sentChatID, sentContent string
- tool.SetSendCallback(func(channel, chatID, content string) error {
+ tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
sentChannel = channel
sentChatID = chatID
sentContent = content
+ if ToolAgentID(ctx) != "" || ToolSessionKey(ctx) != "" || ToolSessionScope(ctx) != nil {
+ t.Fatalf("expected empty turn metadata in basic context, got agent=%q session=%q scope=%+v",
+ ToolAgentID(ctx), ToolSessionKey(ctx), ToolSessionScope(ctx))
+ }
return nil
})
@@ -61,7 +67,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
tool := NewMessageTool()
var sentChannel, sentChatID string
- tool.SetSendCallback(func(channel, chatID, content string) error {
+ tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
sentChannel = channel
sentChatID = chatID
return nil
@@ -96,7 +102,7 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
tool := NewMessageTool()
sendErr := errors.New("network error")
- tool.SetSendCallback(func(channel, chatID, content string) error {
+ tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
return sendErr
})
@@ -149,7 +155,7 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
tool := NewMessageTool()
// No WithToolContext — channel/chatID are empty
- tool.SetSendCallback(func(channel, chatID, content string) error {
+ tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
return nil
})
@@ -251,4 +257,75 @@ func TestMessageTool_Parameters(t *testing.T) {
if chatIDProp["type"] != "string" {
t.Error("Expected chat_id type to be 'string'")
}
+
+ // Check reply_to_message_id property (optional)
+ replyToProp, ok := props["reply_to_message_id"].(map[string]any)
+ if !ok {
+ t.Error("Expected 'reply_to_message_id' property")
+ }
+ if replyToProp["type"] != "string" {
+ t.Error("Expected reply_to_message_id type to be 'string'")
+ }
+}
+
+func TestMessageTool_Execute_WithReplyToMessageID(t *testing.T) {
+ tool := NewMessageTool()
+
+ var sentReplyTo string
+ tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
+ sentReplyTo = replyToMessageID
+ return nil
+ })
+
+ ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
+ args := map[string]any{
+ "content": "Reply test",
+ "reply_to_message_id": "msg-123",
+ }
+
+ result := tool.Execute(ctx, args)
+ if result.IsError {
+ t.Fatalf("expected success, got error: %s", result.ForLLM)
+ }
+ if sentReplyTo != "msg-123" {
+ t.Fatalf("expected reply_to_message_id msg-123, got %q", sentReplyTo)
+ }
+}
+
+func TestMessageTool_Execute_PropagatesTurnSessionMetadata(t *testing.T) {
+ tool := NewMessageTool()
+
+ var gotAgentID, gotSessionKey string
+ var gotScope *session.SessionScope
+ tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
+ gotAgentID = ToolAgentID(ctx)
+ gotSessionKey = ToolSessionKey(ctx)
+ gotScope = ToolSessionScope(ctx)
+ return nil
+ })
+
+ ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
+ ctx = WithToolSessionContext(ctx, "main", "sk_v1_tool", &session.SessionScope{
+ Version: session.ScopeVersionV1,
+ AgentID: "main",
+ Channel: "telegram",
+ Dimensions: []string{"chat"},
+ Values: map[string]string{
+ "chat": "direct:test-chat-id",
+ },
+ })
+
+ result := tool.Execute(ctx, map[string]any{"content": "Hello, world!"})
+ if result.IsError {
+ t.Fatalf("expected success, got error: %s", result.ForLLM)
+ }
+ if gotAgentID != "main" {
+ t.Fatalf("ToolAgentID() = %q, want main", gotAgentID)
+ }
+ if gotSessionKey != "sk_v1_tool" {
+ t.Fatalf("ToolSessionKey() = %q, want sk_v1_tool", gotSessionKey)
+ }
+ if gotScope == nil || gotScope.Values["chat"] != "direct:test-chat-id" {
+ t.Fatalf("ToolSessionScope() = %+v, want chat scope", gotScope)
+ }
}
diff --git a/pkg/tools/integration/reaction.go b/pkg/tools/integration/reaction.go
new file mode 100644
index 000000000..5a8dc87be
--- /dev/null
+++ b/pkg/tools/integration/reaction.go
@@ -0,0 +1,87 @@
+package integrationtools
+
+import (
+ "context"
+ "fmt"
+)
+
+type ReactionCallback func(ctx context.Context, channel, chatID, messageID string) error
+
+type ReactionTool struct {
+ reactionCallback ReactionCallback
+}
+
+func NewReactionTool() *ReactionTool {
+ return &ReactionTool{}
+}
+
+func (t *ReactionTool) Name() string {
+ return "reaction"
+}
+
+func (t *ReactionTool) Description() string {
+ return "Add a reaction to a message. Defaults to the current inbound message when message_id is omitted."
+}
+
+func (t *ReactionTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "message_id": map[string]any{
+ "type": "string",
+ "description": "Optional: target message ID; defaults to the current inbound message",
+ },
+ "channel": map[string]any{
+ "type": "string",
+ "description": "Optional: target channel (telegram, whatsapp, etc.)",
+ },
+ "chat_id": map[string]any{
+ "type": "string",
+ "description": "Optional: target chat/user ID",
+ },
+ },
+ }
+}
+
+func (t *ReactionTool) SetReactionCallback(callback ReactionCallback) {
+ t.reactionCallback = callback
+}
+
+func (t *ReactionTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ channel, _ := args["channel"].(string)
+ chatID, _ := args["chat_id"].(string)
+ messageID, _ := args["message_id"].(string)
+
+ if channel == "" {
+ channel = ToolChannel(ctx)
+ }
+ if chatID == "" {
+ chatID = ToolChatID(ctx)
+ }
+ if messageID == "" {
+ messageID = ToolMessageID(ctx)
+ }
+
+ if channel == "" || chatID == "" {
+ return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true}
+ }
+ if messageID == "" {
+ return &ToolResult{ForLLM: "message_id is required", IsError: true}
+ }
+ if t.reactionCallback == nil {
+ return &ToolResult{ForLLM: "Reaction not configured", IsError: true}
+ }
+
+ if err := t.reactionCallback(ctx, channel, chatID, messageID); err != nil {
+ return &ToolResult{
+ ForLLM: fmt.Sprintf("adding reaction: %v", err),
+ IsError: true,
+ Err: err,
+ }
+ }
+
+ return &ToolResult{
+ ForLLM: fmt.Sprintf("Reaction added to %s:%s message %s", channel, chatID, messageID),
+ Silent: true,
+ }
+}
diff --git a/pkg/tools/integration/reaction_test.go b/pkg/tools/integration/reaction_test.go
new file mode 100644
index 000000000..f579fd914
--- /dev/null
+++ b/pkg/tools/integration/reaction_test.go
@@ -0,0 +1,96 @@
+package integrationtools
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+func TestReactionTool_Execute_UsesContextMessageIDByDefault(t *testing.T) {
+ tool := NewReactionTool()
+
+ var gotChannel, gotChatID, gotMessageID string
+ tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error {
+ gotChannel = channel
+ gotChatID = chatID
+ gotMessageID = messageID
+ return nil
+ })
+
+ ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-100", "")
+ result := tool.Execute(ctx, map[string]any{})
+ if result.IsError {
+ t.Fatalf("expected success, got error: %s", result.ForLLM)
+ }
+ if gotChannel != "telegram" || gotChatID != "chat-1" || gotMessageID != "msg-100" {
+ t.Fatalf("unexpected callback args: channel=%q chatID=%q messageID=%q", gotChannel, gotChatID, gotMessageID)
+ }
+}
+
+func TestReactionTool_Execute_AllowsExplicitMessageIDOverride(t *testing.T) {
+ tool := NewReactionTool()
+
+ var gotMessageID string
+ tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error {
+ gotMessageID = messageID
+ return nil
+ })
+
+ ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-context", "")
+ result := tool.Execute(ctx, map[string]any{"message_id": "msg-explicit"})
+ if result.IsError {
+ t.Fatalf("expected success, got error: %s", result.ForLLM)
+ }
+ if gotMessageID != "msg-explicit" {
+ t.Fatalf("expected explicit message id, got %q", gotMessageID)
+ }
+}
+
+func TestReactionTool_Execute_MissingMessageID(t *testing.T) {
+ tool := NewReactionTool()
+ tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { return nil })
+
+ ctx := WithToolContext(context.Background(), "telegram", "chat-1")
+ result := tool.Execute(ctx, map[string]any{})
+ if !result.IsError {
+ t.Fatal("expected error")
+ }
+ if result.ForLLM != "message_id is required" {
+ t.Fatalf("unexpected error message: %q", result.ForLLM)
+ }
+}
+
+func TestReactionTool_Execute_CallbackError(t *testing.T) {
+ tool := NewReactionTool()
+ tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error {
+ return errors.New("unsupported")
+ })
+
+ ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-100", "")
+ result := tool.Execute(ctx, map[string]any{})
+ if !result.IsError {
+ t.Fatal("expected error")
+ }
+ if result.Err == nil {
+ t.Fatal("expected wrapped error")
+ }
+}
+
+func TestReactionTool_Parameters(t *testing.T) {
+ tool := NewReactionTool()
+ params := tool.Parameters()
+
+ props, ok := params["properties"].(map[string]any)
+ if !ok {
+ t.Fatal("expected properties map")
+ }
+ if _, ok := props["message_id"]; !ok {
+ t.Fatal("expected message_id parameter")
+ }
+ if _, ok := props["channel"]; !ok {
+ t.Fatal("expected channel parameter")
+ }
+ if _, ok := props["chat_id"]; !ok {
+ t.Fatal("expected chat_id parameter")
+ }
+}
diff --git a/pkg/tools/integration/shared.go b/pkg/tools/integration/shared.go
new file mode 100644
index 000000000..cc6aa3f28
--- /dev/null
+++ b/pkg/tools/integration/shared.go
@@ -0,0 +1,77 @@
+package integrationtools
+
+import (
+ "context"
+
+ "github.com/sipeed/picoclaw/pkg/session"
+ toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
+)
+
+type (
+ Tool = toolshared.Tool
+ ToolResult = toolshared.ToolResult
+ AsyncCallback = toolshared.AsyncCallback
+)
+
+func WithToolContext(ctx context.Context, channel, chatID string) context.Context {
+ return toolshared.WithToolContext(ctx, channel, chatID)
+}
+
+func WithToolInboundContext(
+ ctx context.Context,
+ channel, chatID, messageID, replyToMessageID string,
+) context.Context {
+ return toolshared.WithToolInboundContext(ctx, channel, chatID, messageID, replyToMessageID)
+}
+
+func WithToolSessionContext(
+ ctx context.Context,
+ agentID, sessionKey string,
+ scope *session.SessionScope,
+) context.Context {
+ return toolshared.WithToolSessionContext(ctx, agentID, sessionKey, scope)
+}
+
+func ToolChannel(ctx context.Context) string {
+ return toolshared.ToolChannel(ctx)
+}
+
+func ToolChatID(ctx context.Context) string {
+ return toolshared.ToolChatID(ctx)
+}
+
+func ToolMessageID(ctx context.Context) string {
+ return toolshared.ToolMessageID(ctx)
+}
+
+func ToolAgentID(ctx context.Context) string {
+ return toolshared.ToolAgentID(ctx)
+}
+
+func ToolSessionKey(ctx context.Context) string {
+ return toolshared.ToolSessionKey(ctx)
+}
+
+func ToolSessionScope(ctx context.Context) *session.SessionScope {
+ return toolshared.ToolSessionScope(ctx)
+}
+
+func ErrorResult(message string) *ToolResult {
+ return toolshared.ErrorResult(message)
+}
+
+func SilentResult(forLLM string) *ToolResult {
+ return toolshared.SilentResult(forLLM)
+}
+
+func NewToolResult(forLLM string) *ToolResult {
+ return toolshared.NewToolResult(forLLM)
+}
+
+func UserResult(content string) *ToolResult {
+ return toolshared.UserResult(content)
+}
+
+func MediaResult(forLLM string, mediaRefs []string) *ToolResult {
+ return toolshared.MediaResult(forLLM, mediaRefs)
+}
diff --git a/pkg/tools/skills_install.go b/pkg/tools/integration/skills_install.go
similarity index 56%
rename from pkg/tools/skills_install.go
rename to pkg/tools/integration/skills_install.go
index 71bfe730b..1824f2c0a 100644
--- a/pkg/tools/skills_install.go
+++ b/pkg/tools/integration/skills_install.go
@@ -1,4 +1,4 @@
-package tools
+package integrationtools
import (
"context"
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"sync"
"time"
@@ -15,6 +16,10 @@ import (
"github.com/sipeed/picoclaw/pkg/utils"
)
+const defaultSkillRegistryName = "github"
+
+var persistInstalledSkillOriginMeta = writeOriginMeta
+
// InstallSkillTool allows the LLM agent to install skills from registries.
// It shares the same RegistryManager that FindSkillsTool uses,
// so all registries configured in config are available for installation.
@@ -40,7 +45,7 @@ func (t *InstallSkillTool) Name() string {
}
func (t *InstallSkillTool) Description() string {
- return "Install a skill from a registry by slug. Downloads and extracts the skill into the workspace. Use find_skills first to discover available skills."
+ return "Install a skill from a registry by slug. Defaults to GitHub when registry is omitted. Downloads and extracts the skill into the workspace. Use find_skills first to discover available skills."
}
func (t *InstallSkillTool) Parameters() map[string]any {
@@ -57,14 +62,14 @@ func (t *InstallSkillTool) Parameters() map[string]any {
},
"registry": map[string]any{
"type": "string",
- "description": "Registry to install from (required, e.g., 'clawhub')",
+ "description": "Registry to install from (optional, defaults to 'github')",
},
"force": map[string]any{
"type": "boolean",
"description": "Force reinstall if skill already exists (default false)",
},
},
- "required": []string{"slug", "registry"},
+ "required": []string{"slug"},
}
}
@@ -74,45 +79,86 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
t.mu.Lock()
defer t.mu.Unlock()
- // Validate slug
slug, _ := args["slug"].(string)
- if err := utils.ValidateSkillIdentifier(slug); err != nil {
- return ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error()))
+ if strings.TrimSpace(slug) == "" {
+ return ErrorResult("identifier is required and must be a non-empty string")
}
// Validate registry
registryName, _ := args["registry"].(string)
+ if registryName == "" {
+ registryName = defaultSkillRegistryName
+ }
if err := utils.ValidateSkillIdentifier(registryName); err != nil {
return ErrorResult(fmt.Sprintf("invalid registry %q: error: %s", registryName, err.Error()))
}
- version, _ := args["version"].(string)
- force, _ := args["force"].(bool)
-
- // Check if already installed.
- skillsDir := filepath.Join(t.workspace, "skills")
- targetDir := filepath.Join(skillsDir, slug)
-
- if !force {
- if _, err := os.Stat(targetDir); err == nil {
- return ErrorResult(
- fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir),
- )
- }
- } else {
- // Force: remove existing if present.
- os.RemoveAll(targetDir)
- }
-
// Resolve which registry to use.
registry := t.registryMgr.GetRegistry(registryName)
if registry == nil {
return ErrorResult(fmt.Sprintf("registry %q not found", registryName))
}
+ // Validate target and resolve install directory.
+ dirName, err := registry.ResolveInstallDirName(slug)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error()))
+ }
+
+ version, _ := args["version"].(string)
+ force, _ := args["force"].(bool)
+
+ // Check if already installed.
+ skillsDir := filepath.Join(t.workspace, "skills")
+ targetDir := filepath.Join(skillsDir, dirName)
+ backupDir := ""
+ restorePreviousInstall := func() {
+ if backupDir == "" {
+ return
+ }
+ if rmErr := os.RemoveAll(targetDir); rmErr != nil {
+ logger.ErrorCF("tool", "Failed to remove failed install before restore",
+ map[string]any{
+ "tool": "install_skill",
+ "target_dir": targetDir,
+ "error": rmErr.Error(),
+ })
+ return
+ }
+ if restoreErr := os.Rename(backupDir, targetDir); restoreErr != nil {
+ logger.ErrorCF("tool", "Failed to restore previous install after failed reinstall",
+ map[string]any{
+ "tool": "install_skill",
+ "backup_dir": backupDir,
+ "target_dir": targetDir,
+ "error": restoreErr.Error(),
+ })
+ return
+ }
+ backupDir = ""
+ }
+
+ if !force {
+ if _, statErr := os.Stat(targetDir); statErr == nil {
+ return ErrorResult(
+ fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir),
+ )
+ }
+ } else {
+ if _, statErr := os.Stat(targetDir); statErr == nil {
+ backupDir = filepath.Join(skillsDir, fmt.Sprintf(".%s.picoclaw-backup-%d", dirName, time.Now().UnixNano()))
+ if renameErr := os.Rename(targetDir, backupDir); renameErr != nil {
+ return ErrorResult(fmt.Sprintf("failed to prepare reinstall for %q: %v", slug, renameErr))
+ }
+ } else if !os.IsNotExist(statErr) {
+ return ErrorResult(fmt.Sprintf("failed to inspect existing install for %q: %v", slug, statErr))
+ }
+ }
+
// Ensure skills directory exists.
- if err := os.MkdirAll(skillsDir, 0o755); err != nil {
- return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err))
+ if mkdirErr := os.MkdirAll(skillsDir, 0o755); mkdirErr != nil {
+ restorePreviousInstall()
+ return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", mkdirErr))
}
// Download and install (handles metadata, version resolution, extraction).
@@ -128,6 +174,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
"error": rmErr.Error(),
})
}
+ restorePreviousInstall()
return ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err))
}
@@ -142,11 +189,26 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
"error": rmErr.Error(),
})
}
+ restorePreviousInstall()
return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug))
}
+ if !workspaceHasValidInstalledSkill(t.workspace, dirName) {
+ rmErr := os.RemoveAll(targetDir)
+ if rmErr != nil {
+ logger.ErrorCF("tool", "Failed to remove invalid installed skill",
+ map[string]any{
+ "tool": "install_skill",
+ "target_dir": targetDir,
+ "error": rmErr.Error(),
+ })
+ }
+ restorePreviousInstall()
+ return ErrorResult(fmt.Sprintf("failed to install %q: registry archive is not a valid skill", slug))
+ }
+
// Write origin metadata.
- if err := writeOriginMeta(targetDir, registry.Name(), slug, result.Version); err != nil {
+ if err := persistInstalledSkillOriginMeta(targetDir, registry, slug, result.Version); err != nil {
logger.ErrorCF("tool", "Failed to write origin metadata",
map[string]any{
"tool": "install_skill",
@@ -156,7 +218,27 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
"slug": slug,
"version": result.Version,
})
- _ = err
+ rmErr := os.RemoveAll(targetDir)
+ if rmErr != nil {
+ logger.ErrorCF("tool", "Failed to roll back install after metadata write failure",
+ map[string]any{
+ "tool": "install_skill",
+ "target_dir": targetDir,
+ "error": rmErr.Error(),
+ })
+ }
+ restorePreviousInstall()
+ return ErrorResult(fmt.Sprintf("failed to persist skill metadata for %q: %v", slug, err))
+ }
+ if backupDir != "" {
+ if rmErr := os.RemoveAll(backupDir); rmErr != nil {
+ logger.ErrorCF("tool", "Failed to remove previous install backup after successful reinstall",
+ map[string]any{
+ "tool": "install_skill",
+ "backup_dir": backupDir,
+ "error": rmErr.Error(),
+ })
+ }
}
// Build result with moderation warning if suspicious.
@@ -178,17 +260,27 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
// originMeta tracks which registry a skill was installed from.
type originMeta struct {
Version int `json:"version"`
+ OriginKind string `json:"origin_kind,omitempty"`
Registry string `json:"registry"`
Slug string `json:"slug"`
+ RegistryURL string `json:"registry_url,omitempty"`
InstalledVersion string `json:"installed_version"`
InstalledAt int64 `json:"installed_at"`
}
-func writeOriginMeta(targetDir, registryName, slug, version string) error {
+func writeOriginMeta(targetDir string, registry skills.SkillRegistry, slug, version string) error {
+ normalizedSlug, registryURL := skills.BuildInstallMetadataForRegistryInstance(registry, slug, version)
+ registryName := ""
+ if registry != nil {
+ registryName = registry.Name()
+ }
+
meta := originMeta{
Version: 1,
+ OriginKind: "third_party",
Registry: registryName,
- Slug: slug,
+ Slug: normalizedSlug,
+ RegistryURL: registryURL,
InstalledVersion: version,
InstalledAt: time.Now().UnixMilli(),
}
@@ -201,3 +293,16 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error {
// Use unified atomic write utility with explicit sync for flash storage reliability.
return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
}
+
+func workspaceHasValidInstalledSkill(workspace, directory string) bool {
+ loader := skills.NewSkillsLoader(workspace, "", "")
+ for _, skill := range loader.ListSkills() {
+ if skill.Source != "workspace" {
+ continue
+ }
+ if filepath.Base(filepath.Dir(skill.Path)) == directory {
+ return true
+ }
+ }
+ return false
+}
diff --git a/pkg/tools/integration/skills_install_test.go b/pkg/tools/integration/skills_install_test.go
new file mode 100644
index 000000000..01d2fd2bc
--- /dev/null
+++ b/pkg/tools/integration/skills_install_test.go
@@ -0,0 +1,423 @@
+package integrationtools
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
+)
+
+type mockInstallRegistry struct{}
+
+const validSkillMarkdown = "---\nname: pr-review\ndescription: Review pull requests\n---\n# PR Review\n"
+
+func (m *mockInstallRegistry) Name() string { return "clawhub" }
+
+func (m *mockInstallRegistry) ResolveInstallDirName(target string) (string, error) {
+ return target, nil
+}
+
+func (m *mockInstallRegistry) SkillURL(slug, _ string) string { return slug }
+
+func (m *mockInstallRegistry) Search(context.Context, string, int) ([]skills.SearchResult, error) {
+ return nil, nil
+}
+
+func (m *mockInstallRegistry) GetSkillMeta(context.Context, string) (*skills.SkillMeta, error) {
+ return nil, nil
+}
+
+func (m *mockInstallRegistry) DownloadAndInstall(
+ _ context.Context,
+ _ string,
+ _ string,
+ targetDir string,
+) (*skills.InstallResult, error) {
+ if err := os.MkdirAll(targetDir, 0o755); err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(filepath.Join(targetDir, "SKILL.md"), []byte(validSkillMarkdown), 0o600); err != nil {
+ return nil, err
+ }
+ return &skills.InstallResult{Version: "test"}, nil
+}
+
+type mockGitHubInstallRegistry struct{}
+
+func (m *mockGitHubInstallRegistry) Name() string { return "github" }
+
+func (m *mockGitHubInstallRegistry) ResolveInstallDirName(target string) (string, error) {
+ return "pr-review", nil
+}
+
+func (m *mockGitHubInstallRegistry) SkillURL(slug, _ string) string { return slug }
+
+func (m *mockGitHubInstallRegistry) Search(context.Context, string, int) ([]skills.SearchResult, error) {
+ return nil, nil
+}
+
+func (m *mockGitHubInstallRegistry) GetSkillMeta(context.Context, string) (*skills.SkillMeta, error) {
+ return nil, nil
+}
+
+func (m *mockGitHubInstallRegistry) DownloadAndInstall(
+ _ context.Context,
+ _ string,
+ _ string,
+ targetDir string,
+) (*skills.InstallResult, error) {
+ if err := os.MkdirAll(targetDir, 0o755); err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(filepath.Join(targetDir, "SKILL.md"), []byte(validSkillMarkdown), 0o600); err != nil {
+ return nil, err
+ }
+ return &skills.InstallResult{Version: "main"}, nil
+}
+
+type stubGitHubInstallRegistry struct {
+ *skills.GitHubRegistry
+}
+
+func (m *stubGitHubInstallRegistry) DownloadAndInstall(
+ _ context.Context,
+ _ string,
+ _ string,
+ targetDir string,
+) (*skills.InstallResult, error) {
+ if err := os.MkdirAll(targetDir, 0o755); err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(filepath.Join(targetDir, "SKILL.md"), []byte(validSkillMarkdown), 0o600); err != nil {
+ return nil, err
+ }
+ return &skills.InstallResult{Version: "main"}, nil
+}
+
+type mockInvalidInstallRegistry struct{}
+
+type mockFailingInstallRegistry struct{}
+
+func (m *mockInvalidInstallRegistry) Name() string { return "clawhub" }
+
+func (m *mockInvalidInstallRegistry) ResolveInstallDirName(target string) (string, error) {
+ return target, nil
+}
+
+func (m *mockInvalidInstallRegistry) SkillURL(slug, _ string) string { return slug }
+
+func (m *mockInvalidInstallRegistry) Search(context.Context, string, int) ([]skills.SearchResult, error) {
+ return nil, nil
+}
+
+func (m *mockInvalidInstallRegistry) GetSkillMeta(context.Context, string) (*skills.SkillMeta, error) {
+ return nil, nil
+}
+
+func (m *mockInvalidInstallRegistry) DownloadAndInstall(
+ _ context.Context,
+ _ string,
+ _ string,
+ targetDir string,
+) (*skills.InstallResult, error) {
+ if err := os.MkdirAll(targetDir, 0o755); err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(
+ filepath.Join(targetDir, "SKILL.md"),
+ []byte("---\nname: bad_skill\ndescription: invalid name\n---\n# Invalid\n"),
+ 0o600,
+ ); err != nil {
+ return nil, err
+ }
+ return &skills.InstallResult{Version: "test"}, nil
+}
+
+func (m *mockFailingInstallRegistry) Name() string { return "clawhub" }
+
+func (m *mockFailingInstallRegistry) ResolveInstallDirName(target string) (string, error) {
+ return target, nil
+}
+
+func (m *mockFailingInstallRegistry) SkillURL(slug, _ string) string { return slug }
+
+func (m *mockFailingInstallRegistry) Search(context.Context, string, int) ([]skills.SearchResult, error) {
+ return nil, nil
+}
+
+func (m *mockFailingInstallRegistry) GetSkillMeta(context.Context, string) (*skills.SkillMeta, error) {
+ return nil, nil
+}
+
+func (m *mockFailingInstallRegistry) DownloadAndInstall(
+ _ context.Context,
+ _ string,
+ _ string,
+ _ string,
+) (*skills.InstallResult, error) {
+ return nil, assert.AnError
+}
+
+func TestInstallSkillToolName(t *testing.T) {
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
+ assert.Equal(t, "install_skill", tool.Name())
+}
+
+func TestInstallSkillToolMissingSlug(t *testing.T) {
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
+ result := tool.Execute(context.Background(), map[string]any{})
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string")
+}
+
+func TestInstallSkillToolEmptySlug(t *testing.T) {
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": " ",
+ })
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string")
+}
+
+func TestInstallSkillToolUnsafeSlug(t *testing.T) {
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(skills.NewClawHubRegistry(skills.ClawHubConfig{Enabled: true}))
+ tool := NewInstallSkillTool(registryMgr, t.TempDir())
+
+ cases := []string{
+ "../etc/passwd",
+ "path/traversal",
+ "path\\traversal",
+ }
+
+ for _, slug := range cases {
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": slug,
+ "registry": "clawhub",
+ })
+ assert.True(t, result.IsError, "slug %q should be rejected", slug)
+ assert.Contains(t, result.ForLLM, "invalid slug")
+ }
+}
+
+func TestInstallSkillToolAlreadyExists(t *testing.T) {
+ workspace := t.TempDir()
+ skillDir := filepath.Join(workspace, "skills", "existing-skill")
+ require.NoError(t, os.MkdirAll(skillDir, 0o755))
+
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(&mockInstallRegistry{})
+ tool := NewInstallSkillTool(registryMgr, workspace)
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": "existing-skill",
+ "registry": "clawhub",
+ })
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "already installed")
+}
+
+func TestInstallSkillToolRegistryNotFound(t *testing.T) {
+ workspace := t.TempDir()
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": "some-skill",
+ "registry": "nonexistent",
+ })
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "registry")
+ assert.Contains(t, result.ForLLM, "not found")
+}
+
+func TestInstallSkillToolParameters(t *testing.T) {
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
+ params := tool.Parameters()
+
+ props, ok := params["properties"].(map[string]any)
+ assert.True(t, ok)
+ assert.Contains(t, props, "slug")
+ assert.Contains(t, props, "version")
+ assert.Contains(t, props, "registry")
+ assert.Contains(t, props, "force")
+
+ required, ok := params["required"].([]string)
+ assert.True(t, ok)
+ assert.Contains(t, required, "slug")
+ assert.NotContains(t, required, "registry")
+}
+
+func TestInstallSkillToolMissingRegistry(t *testing.T) {
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(&mockGitHubInstallRegistry{})
+ tool := NewInstallSkillTool(registryMgr, t.TempDir())
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": "some-skill",
+ })
+ assert.False(t, result.IsError)
+ assert.Contains(t, result.ForLLM, `Successfully installed skill`)
+}
+
+func TestInstallSkillToolAllowsGitHubURLSlug(t *testing.T) {
+ registry := skills.GitHubRegistryConfig{Enabled: true, BaseURL: "https://github.com"}.BuildRegistry()
+ githubRegistry, ok := registry.(*skills.GitHubRegistry)
+ require.True(t, ok)
+
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(&stubGitHubInstallRegistry{GitHubRegistry: githubRegistry})
+ workspace := t.TempDir()
+ tool := NewInstallSkillTool(registryMgr, workspace)
+
+ slug := "https://github.com/synthetic-lab/octofriend/tree/main/.agents/skills/pr-review"
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": slug,
+ "registry": "github",
+ })
+
+ assert.False(t, result.IsError)
+ assert.Contains(t, result.ForLLM, `Successfully installed skill`)
+
+ data, err := os.ReadFile(filepath.Join(workspace, "skills", "pr-review", ".skill-origin.json"))
+ require.NoError(t, err)
+
+ var meta originMeta
+ require.NoError(t, json.Unmarshal(data, &meta))
+ assert.Equal(t, "third_party", meta.OriginKind)
+ assert.Equal(t, "github", meta.Registry)
+ assert.Equal(t, "synthetic-lab/octofriend/.agents/skills/pr-review", meta.Slug)
+ assert.Equal(t, slug, meta.RegistryURL)
+ assert.Equal(t, "main", meta.InstalledVersion)
+ assert.NotZero(t, meta.InstalledAt)
+}
+
+func TestInstallSkillToolPreservesGitHubSourceURLWithEnterpriseRegistry(t *testing.T) {
+ registry := skills.GitHubRegistryConfig{Enabled: true, BaseURL: "https://ghe.example.com/git"}.BuildRegistry()
+ githubRegistry, ok := registry.(*skills.GitHubRegistry)
+ require.True(t, ok)
+
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(&stubGitHubInstallRegistry{GitHubRegistry: githubRegistry})
+ workspace := t.TempDir()
+ tool := NewInstallSkillTool(registryMgr, workspace)
+
+ slug := "https://github.com/synthetic-lab/octofriend/tree/main/.agents/skills/pr-review"
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": slug,
+ "registry": "github",
+ })
+
+ assert.False(t, result.IsError)
+
+ data, err := os.ReadFile(filepath.Join(workspace, "skills", "pr-review", ".skill-origin.json"))
+ require.NoError(t, err)
+
+ var meta originMeta
+ require.NoError(t, json.Unmarshal(data, &meta))
+ assert.Equal(t, "synthetic-lab/octofriend/.agents/skills/pr-review", meta.Slug)
+ assert.Equal(t, slug, meta.RegistryURL)
+ assert.Equal(t, "main", meta.InstalledVersion)
+}
+
+func TestInstallSkillToolRejectsInvalidInstalledSkill(t *testing.T) {
+ workspace := t.TempDir()
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(&mockInvalidInstallRegistry{})
+ tool := NewInstallSkillTool(registryMgr, workspace)
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": "broken-skill",
+ "registry": "clawhub",
+ })
+
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "not a valid skill")
+ _, err := os.Stat(filepath.Join(workspace, "skills", "broken-skill"))
+ assert.True(t, os.IsNotExist(err))
+}
+
+func TestInstallSkillToolRollsBackOnOriginMetadataWriteFailure(t *testing.T) {
+ workspace := t.TempDir()
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(&mockInstallRegistry{})
+ tool := NewInstallSkillTool(registryMgr, workspace)
+
+ previousPersist := persistInstalledSkillOriginMeta
+ persistInstalledSkillOriginMeta = func(string, skills.SkillRegistry, string, string) error {
+ return assert.AnError
+ }
+ defer func() {
+ persistInstalledSkillOriginMeta = previousPersist
+ }()
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": "rollback-skill",
+ "registry": "clawhub",
+ })
+
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "failed to persist skill metadata")
+ _, err := os.Stat(filepath.Join(workspace, "skills", "rollback-skill"))
+ assert.True(t, os.IsNotExist(err))
+}
+
+func TestInstallSkillToolForceReinstallRestoresPreviousSkillAfterDownloadFailure(t *testing.T) {
+ workspace := t.TempDir()
+ skillDir := filepath.Join(workspace, "skills", "existing-skill")
+ require.NoError(t, os.MkdirAll(skillDir, 0o755))
+ oldContent := []byte("---\nname: existing-skill\ndescription: Existing skill\n---\n# Existing\n")
+ require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), oldContent, 0o600))
+
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(&mockFailingInstallRegistry{})
+ tool := NewInstallSkillTool(registryMgr, workspace)
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": "existing-skill",
+ "registry": "clawhub",
+ "force": true,
+ })
+
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "failed to install")
+
+ gotContent, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md"))
+ require.NoError(t, err)
+ assert.Equal(t, oldContent, gotContent)
+}
+
+func TestInstallSkillToolForceReinstallRestoresPreviousSkillAfterMetadataFailure(t *testing.T) {
+ workspace := t.TempDir()
+ skillDir := filepath.Join(workspace, "skills", "existing-skill")
+ require.NoError(t, os.MkdirAll(skillDir, 0o755))
+ oldContent := []byte("---\nname: existing-skill\ndescription: Existing skill\n---\n# Existing\n")
+ require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), oldContent, 0o600))
+
+ registryMgr := skills.NewRegistryManager()
+ registryMgr.AddRegistry(&mockInstallRegistry{})
+ tool := NewInstallSkillTool(registryMgr, workspace)
+
+ previousPersist := persistInstalledSkillOriginMeta
+ persistInstalledSkillOriginMeta = func(string, skills.SkillRegistry, string, string) error {
+ return assert.AnError
+ }
+ defer func() {
+ persistInstalledSkillOriginMeta = previousPersist
+ }()
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "slug": "existing-skill",
+ "registry": "clawhub",
+ "force": true,
+ })
+
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "failed to persist skill metadata")
+
+ gotContent, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md"))
+ require.NoError(t, err)
+ assert.Equal(t, oldContent, gotContent)
+}
diff --git a/pkg/tools/skills_search.go b/pkg/tools/integration/skills_search.go
similarity index 99%
rename from pkg/tools/skills_search.go
rename to pkg/tools/integration/skills_search.go
index 2b6cffd38..f080aba95 100644
--- a/pkg/tools/skills_search.go
+++ b/pkg/tools/integration/skills_search.go
@@ -1,4 +1,4 @@
-package tools
+package integrationtools
import (
"context"
diff --git a/pkg/tools/skills_search_test.go b/pkg/tools/integration/skills_search_test.go
similarity index 99%
rename from pkg/tools/skills_search_test.go
rename to pkg/tools/integration/skills_search_test.go
index 0e5387cf5..fcce48b49 100644
--- a/pkg/tools/skills_search_test.go
+++ b/pkg/tools/integration/skills_search_test.go
@@ -1,4 +1,4 @@
-package tools
+package integrationtools
import (
"context"
diff --git a/pkg/tools/integration/tts_send.go b/pkg/tools/integration/tts_send.go
new file mode 100644
index 000000000..6c9135624
--- /dev/null
+++ b/pkg/tools/integration/tts_send.go
@@ -0,0 +1,82 @@
+package integrationtools
+
+import (
+ "context"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/audio/tts"
+ "github.com/sipeed/picoclaw/pkg/media"
+)
+
+type SendTTSTool struct {
+ provider tts.TTSProvider
+ mediaStore media.MediaStore
+}
+
+func NewSendTTSTool(provider tts.TTSProvider, store media.MediaStore) *SendTTSTool {
+ return &SendTTSTool{
+ provider: provider,
+ mediaStore: store,
+ }
+}
+
+func (t *SendTTSTool) Name() string { return "send_tts" }
+
+func (t *SendTTSTool) Description() string {
+ return "Synthesize speech from text and send it as an audio file to the user."
+}
+
+func (t *SendTTSTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "text": map[string]any{
+ "type": "string",
+ "description": "The text to synthesize into speech. NOTE: Reply in a highly concise, conversational, oral style suitable for text-to-speech. Do not use markdown, emojis, asterisks, or code blocks. Speak naturally.",
+ },
+ "filename": map[string]any{
+ "type": "string",
+ "description": "Optional filename for the audio file (e.g., response.ogg).",
+ },
+ },
+ "required": []string{"text"},
+ }
+}
+
+func (t *SendTTSTool) SetMediaStore(store media.MediaStore) {
+ t.mediaStore = store
+}
+
+func (t *SendTTSTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ text, _ := args["text"].(string)
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return ErrorResult("text is required")
+ }
+
+ channel := ToolChannel(ctx)
+ chatID := ToolChatID(ctx)
+ filename, _ := args["filename"].(string)
+
+ ref, err := tts.SynthesizeAndStore(
+ ctx,
+ t.provider,
+ t.mediaStore,
+ text,
+ filename,
+ channel,
+ chatID,
+ )
+ if err != nil {
+ return ErrorResult(err.Error()).WithError(err)
+ }
+
+ // Return with ForUser set to original text, Media containing the audio ref,
+ // and mark as ResponseHandled so the audio is sent immediately without LLM intervention.
+ return &ToolResult{
+ ForLLM: "TTS audio sent",
+ ForUser: text,
+ Media: []string{ref},
+ ResponseHandled: true,
+ }
+}
diff --git a/pkg/tools/web.go b/pkg/tools/integration/web.go
similarity index 72%
rename from pkg/tools/web.go
rename to pkg/tools/integration/web.go
index 342f7458b..75821e40d 100644
--- a/pkg/tools/web.go
+++ b/pkg/tools/integration/web.go
@@ -1,4 +1,4 @@
-package tools
+package integrationtools
import (
"bytes"
@@ -15,6 +15,7 @@ import (
"strings"
"sync/atomic"
"time"
+ "unicode"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -23,6 +24,7 @@ import (
const (
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+ sogouUserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1"
userAgentHonest = "picoclaw/%s (+https://github.com/sipeed/picoclaw; AI assistant bot)"
// HTTP client timeouts for web tool providers.
@@ -46,7 +48,14 @@ var (
reDDGLink = regexp.MustCompile(
`]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)`,
)
- reDDGSnippet = regexp.MustCompile(`([\s\S]*?)`)
+ reDDGSnippet = regexp.MustCompile(
+ `([\s\S]*?)`,
+ )
+ reSogouTitle = regexp.MustCompile(
+ `]*id="sogou_vr_\d+_\d+"[^>]*>\s*(.*?)\s*`,
+ )
+ reSogouSnippet = regexp.MustCompile(`\s*(.*?)\s*`)
+ reSogouRealURL = regexp.MustCompile(`url=([^&]+)`)
)
type APIKeyPool struct {
@@ -91,6 +100,39 @@ type SearchProvider interface {
Search(ctx context.Context, query string, count int, rangeCode string) (string, error)
}
+type SearchResultItem struct {
+ Title string
+ URL string
+ Snippet string
+}
+
+func extractSogouURL(href string) string {
+ match := reSogouRealURL.FindStringSubmatch(href)
+ if len(match) < 2 {
+ return ""
+ }
+ decoded, err := url.QueryUnescape(match[1])
+ if err != nil {
+ return ""
+ }
+ return decoded
+}
+
+func applySogouRangeHint(query string, rangeCode string) string {
+ switch rangeCode {
+ case "d":
+ return query + " 最近一天"
+ case "w":
+ return query + " 最近一周"
+ case "m":
+ return query + " 最近一个月"
+ case "y":
+ return query + " 最近一年"
+ default:
+ return query
+ }
+}
+
func normalizeSearchRange(raw string) (string, error) {
rangeCode := strings.ToLower(strings.TrimSpace(raw))
switch rangeCode {
@@ -218,6 +260,10 @@ func (p *BraveSearchProvider) Search(
count int,
rangeCode string,
) (string, error) {
+ if p.keyPool == nil || len(p.keyPool.keys) == 0 {
+ return "", errors.New("no API key provided")
+ }
+
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
url.QueryEscape(query), count)
if freshness := mapBraveFreshness(rangeCode); freshness != "" {
@@ -317,6 +363,10 @@ func (p *TavilySearchProvider) Search(
count int,
rangeCode string,
) (string, error) {
+ if p.keyPool == nil || len(p.keyPool.keys) == 0 {
+ return "", errors.New("no API key provided")
+ }
+
searchURL := p.baseURL
if searchURL == "" {
searchURL = "https://api.tavily.com/search"
@@ -417,6 +467,104 @@ func (p *TavilySearchProvider) Search(
return "", fmt.Errorf("all api keys failed, last error: %w", lastErr)
}
+type SogouSearchProvider struct {
+ proxy string
+ client *http.Client
+}
+
+func (p *SogouSearchProvider) Search(
+ ctx context.Context,
+ query string,
+ count int,
+ rangeCode string,
+) (string, error) {
+ const sogouWAPURL = "https://wap.sogou.com/web/searchList.jsp"
+
+ results := make([]SearchResultItem, 0, count)
+ seenURLs := make(map[string]bool)
+ maxPages := min(3, (count+1)/2+1)
+
+ for page := 1; page <= maxPages && len(results) < count; page++ {
+ params := url.Values{}
+ params.Set("keyword", applySogouRangeHint(query, rangeCode))
+ params.Set("v", "5")
+ params.Set("p", fmt.Sprintf("%d", page))
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, sogouWAPURL+"?"+params.Encode(), nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("User-Agent", sogouUserAgent)
+
+ resp, err := p.client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("request failed: %w", err)
+ }
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ resp.Body.Close()
+ if err != nil {
+ return "", fmt.Errorf("failed to read response: %w", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("Sogou returned status %d", resp.StatusCode)
+ }
+
+ html := string(body)
+ if len(html) < 200 {
+ break
+ }
+
+ matches := reSogouTitle.FindAllStringSubmatch(html, -1)
+ for _, match := range matches {
+ if len(match) < 3 {
+ continue
+ }
+
+ title := stripTags(match[2])
+ link := extractSogouURL(match[1])
+ if title == "" || link == "" || seenURLs[link] {
+ continue
+ }
+ seenURLs[link] = true
+
+ start := strings.Index(html, match[0])
+ snippet := ""
+ if start >= 0 {
+ after := html[start+len(match[0]):]
+ if len(after) > 2000 {
+ after = after[:2000]
+ }
+ if snippetMatch := reSogouSnippet.FindStringSubmatch(after); len(snippetMatch) > 1 {
+ snippet = stripTags(snippetMatch[1])
+ }
+ }
+
+ results = append(results, SearchResultItem{
+ Title: title,
+ URL: link,
+ Snippet: snippet,
+ })
+ if len(results) >= count {
+ break
+ }
+ }
+ }
+
+ if len(results) == 0 {
+ return fmt.Sprintf("No results for: %s", query), nil
+ }
+
+ lines := []string{fmt.Sprintf("Results for: %s (via Sogou)", query)}
+ for i, item := range results {
+ lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
+ if item.Snippet != "" {
+ lines = append(lines, fmt.Sprintf(" %s", item.Snippet))
+ }
+ }
+ return strings.Join(lines, "\n"), nil
+}
+
type DuckDuckGoSearchProvider struct {
proxy string
client *http.Client
@@ -532,6 +680,10 @@ func (p *PerplexitySearchProvider) Search(
count int,
rangeCode string,
) (string, error) {
+ if p.keyPool == nil || len(p.keyPool.keys) == 0 {
+ return "", errors.New("no API key provided")
+ }
+
searchURL := "https://api.perplexity.ai/chat/completions"
var lastErr error
@@ -637,6 +789,8 @@ func (p *PerplexitySearchProvider) Search(
type SearXNGSearchProvider struct {
baseURL string
+ proxy string
+ client *http.Client
}
func (p *SearXNGSearchProvider) Search(
@@ -645,6 +799,10 @@ func (p *SearXNGSearchProvider) Search(
count int,
rangeCode string,
) (string, error) {
+ if p.baseURL == "" {
+ return "", errors.New("no SearXNG URL provided")
+ }
+
searchURL := fmt.Sprintf("%s/search?q=%s&format=json&categories=general",
strings.TrimSuffix(p.baseURL, "/"),
url.QueryEscape(query))
@@ -657,7 +815,10 @@ func (p *SearXNGSearchProvider) Search(
return "", fmt.Errorf("failed to create request: %w", err)
}
- client := &http.Client{Timeout: 10 * time.Second}
+ client := p.client
+ if client == nil {
+ client = &http.Client{Timeout: searchTimeout}
+ }
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
@@ -719,6 +880,10 @@ func (p *GLMSearchProvider) Search(
count int,
rangeCode string,
) (string, error) {
+ if p.apiKey == "" {
+ return "", errors.New("no API key provided")
+ }
+
searchURL := p.baseURL
if searchURL == "" {
searchURL = "https://open.bigmodel.cn/api/paas/v4/web_search"
@@ -808,6 +973,10 @@ func (p *BaiduSearchProvider) Search(
count int,
rangeCode string,
) (string, error) {
+ if p.apiKey == "" {
+ return "", errors.New("no API key provided")
+ }
+
searchURL := p.baseURL
if searchURL == "" {
searchURL = "https://qianfan.baidubce.com/v2/ai_search/web_search"
@@ -885,11 +1054,13 @@ func (p *BaiduSearchProvider) Search(
}
type WebSearchTool struct {
- provider SearchProvider
- maxResults int
+ provider SearchProvider
+ maxResults int
+ providerResolver func(query string) (SearchProvider, int)
}
type WebSearchToolOptions struct {
+ Provider string
BraveAPIKeys []string
BraveMaxResults int
BraveEnabled bool
@@ -897,6 +1068,8 @@ type WebSearchToolOptions struct {
TavilyBaseURL string
TavilyMaxResults int
TavilyEnabled bool
+ SogouMaxResults int
+ SogouEnabled bool
DuckDuckGoMaxResults int
DuckDuckGoEnabled bool
PerplexityAPIKeys []string
@@ -917,100 +1090,370 @@ type WebSearchToolOptions struct {
Proxy string
}
-func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
- var provider SearchProvider
- maxResults := 10
- // Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > Baidu Search > GLM Search
- if opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 {
+func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions {
+ return WebSearchToolOptions{
+ Provider: cfg.Tools.Web.Provider,
+ BraveAPIKeys: cfg.Tools.Web.Brave.APIKeys.Values(),
+ BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
+ BraveEnabled: cfg.Tools.Web.Brave.Enabled,
+ TavilyAPIKeys: cfg.Tools.Web.Tavily.APIKeys.Values(),
+ TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
+ TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
+ TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
+ SogouMaxResults: cfg.Tools.Web.Sogou.MaxResults,
+ SogouEnabled: cfg.Tools.Web.Sogou.Enabled,
+ DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
+ DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
+ PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(),
+ PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
+ PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
+ SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
+ SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults,
+ SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled,
+ GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey.String(),
+ GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
+ GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
+ GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
+ GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled,
+ BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey.String(),
+ BaiduSearchBaseURL: cfg.Tools.Web.BaiduSearch.BaseURL,
+ BaiduSearchMaxResults: cfg.Tools.Web.BaiduSearch.MaxResults,
+ BaiduSearchEnabled: cfg.Tools.Web.BaiduSearch.Enabled,
+ Proxy: cfg.Tools.Web.Proxy,
+ }
+}
+
+func WebSearchProviderReady(opts WebSearchToolOptions, name string) bool {
+ return opts.providerReady(name)
+}
+
+func ResolveWebSearchProviderName(opts WebSearchToolOptions, query string) (string, error) {
+ return opts.resolveProviderName(query)
+}
+
+var (
+ knownWebSearchProviders = []string{
+ "sogou",
+ "duckduckgo",
+ "brave",
+ "tavily",
+ "perplexity",
+ "searxng",
+ "glm_search",
+ "baidu_search",
+ }
+ autoPrimaryWebSearchProviders = []string{"perplexity", "brave", "searxng", "tavily"}
+ autoFallbackWebSearchProviders = []string{"baidu_search", "glm_search"}
+)
+
+func isKnownWebSearchProvider(name string) bool {
+ name = strings.ToLower(strings.TrimSpace(name))
+ for _, known := range knownWebSearchProviders {
+ if name == known {
+ return true
+ }
+ }
+ return false
+}
+
+func (opts WebSearchToolOptions) providerReady(name string) bool {
+ switch strings.ToLower(strings.TrimSpace(name)) {
+ case "sogou":
+ return opts.SogouEnabled
+ case "duckduckgo":
+ return opts.DuckDuckGoEnabled
+ case "brave":
+ return opts.BraveEnabled && len(opts.BraveAPIKeys) > 0
+ case "tavily":
+ return opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0
+ case "perplexity":
+ return opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0
+ case "searxng":
+ return opts.SearXNGEnabled && strings.TrimSpace(opts.SearXNGBaseURL) != ""
+ case "glm_search":
+ return opts.GLMSearchEnabled && strings.TrimSpace(opts.GLMSearchAPIKey) != ""
+ case "baidu_search":
+ return opts.BaiduSearchEnabled && strings.TrimSpace(opts.BaiduSearchAPIKey) != ""
+ default:
+ return false
+ }
+}
+
+func (opts WebSearchToolOptions) normalizedProviderName() string {
+ providerName := strings.ToLower(strings.TrimSpace(opts.Provider))
+ if providerName != "" && providerName != "auto" && !isKnownWebSearchProvider(providerName) {
+ // Tolerate stale or manually edited config values at runtime by
+ // treating them as "auto" and falling back to the next ready provider.
+ return "auto"
+ }
+ return providerName
+}
+
+func (opts WebSearchToolOptions) resolveProviderName(query string) (string, error) {
+ providerName := opts.normalizedProviderName()
+ if providerName != "" && providerName != "auto" && opts.providerReady(providerName) {
+ return providerName, nil
+ }
+
+ for _, name := range autoPrimaryWebSearchProviders {
+ if opts.providerReady(name) {
+ return name, nil
+ }
+ }
+
+ sogouReady := opts.providerReady("sogou")
+ duckReady := opts.providerReady("duckduckgo")
+ if sogouReady && duckReady {
+ if prefersDuckDuckGoQuery(query) {
+ return "duckduckgo", nil
+ }
+ return "sogou", nil
+ }
+ if sogouReady {
+ return "sogou", nil
+ }
+ if duckReady {
+ return "duckduckgo", nil
+ }
+
+ for _, name := range autoFallbackWebSearchProviders {
+ if opts.providerReady(name) {
+ return name, nil
+ }
+ }
+
+ return "", nil
+}
+
+func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, int, error) {
+ switch strings.ToLower(strings.TrimSpace(name)) {
+ case "", "auto":
+ return nil, 0, nil
+ case "sogou":
+ if !opts.providerReady("sogou") {
+ return nil, 0, nil
+ }
+ client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
+ if err != nil {
+ return nil, 0, fmt.Errorf("failed to create HTTP client for Sogou: %w", err)
+ }
+ maxResults := 10
+ if opts.SogouMaxResults > 0 {
+ maxResults = min(opts.SogouMaxResults, 10)
+ }
+ return &SogouSearchProvider{
+ proxy: opts.Proxy,
+ client: client,
+ }, maxResults, nil
+ case "perplexity":
+ if !opts.providerReady("perplexity") {
+ return nil, 0, nil
+ }
client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout)
if err != nil {
- return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err)
- }
- provider = &PerplexitySearchProvider{
- keyPool: NewAPIKeyPool(opts.PerplexityAPIKeys),
- proxy: opts.Proxy,
- client: client,
+ return nil, 0, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err)
}
+ maxResults := 10
if opts.PerplexityMaxResults > 0 {
maxResults = min(opts.PerplexityMaxResults, 10)
}
- } else if opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 {
+ return &PerplexitySearchProvider{
+ keyPool: NewAPIKeyPool(opts.PerplexityAPIKeys),
+ proxy: opts.Proxy,
+ client: client,
+ }, maxResults, nil
+ case "brave":
+ if !opts.providerReady("brave") {
+ return nil, 0, nil
+ }
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
if err != nil {
- return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err)
+ return nil, 0, fmt.Errorf("failed to create HTTP client for Brave: %w", err)
}
- provider = &BraveSearchProvider{keyPool: NewAPIKeyPool(opts.BraveAPIKeys), proxy: opts.Proxy, client: client}
+ maxResults := 10
if opts.BraveMaxResults > 0 {
maxResults = min(opts.BraveMaxResults, 10)
}
- } else if opts.SearXNGEnabled && opts.SearXNGBaseURL != "" {
- provider = &SearXNGSearchProvider{baseURL: opts.SearXNGBaseURL}
+ return &BraveSearchProvider{
+ keyPool: NewAPIKeyPool(opts.BraveAPIKeys),
+ proxy: opts.Proxy,
+ client: client,
+ }, maxResults, nil
+ case "searxng":
+ if !opts.providerReady("searxng") {
+ return nil, 0, nil
+ }
+ client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
+ if err != nil {
+ return nil, 0, fmt.Errorf("failed to create HTTP client for SearXNG: %w", err)
+ }
+ maxResults := 10
if opts.SearXNGMaxResults > 0 {
maxResults = min(opts.SearXNGMaxResults, 10)
}
- } else if opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 {
+ return &SearXNGSearchProvider{
+ baseURL: opts.SearXNGBaseURL,
+ proxy: opts.Proxy,
+ client: client,
+ }, maxResults, nil
+ case "tavily":
+ if !opts.providerReady("tavily") {
+ return nil, 0, nil
+ }
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
if err != nil {
- return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err)
+ return nil, 0, fmt.Errorf("failed to create HTTP client for Tavily: %w", err)
}
- provider = &TavilySearchProvider{
+ maxResults := 10
+ if opts.TavilyMaxResults > 0 {
+ maxResults = min(opts.TavilyMaxResults, 10)
+ }
+ return &TavilySearchProvider{
keyPool: NewAPIKeyPool(opts.TavilyAPIKeys),
baseURL: opts.TavilyBaseURL,
proxy: opts.Proxy,
client: client,
+ }, maxResults, nil
+ case "duckduckgo":
+ if !opts.providerReady("duckduckgo") {
+ return nil, 0, nil
}
- if opts.TavilyMaxResults > 0 {
- maxResults = min(opts.TavilyMaxResults, 10)
- }
- } else if opts.DuckDuckGoEnabled {
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
if err != nil {
- return nil, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err)
+ return nil, 0, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err)
}
- provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy, client: client}
+ maxResults := 10
if opts.DuckDuckGoMaxResults > 0 {
maxResults = min(opts.DuckDuckGoMaxResults, 10)
}
- } else if opts.BaiduSearchEnabled && opts.BaiduSearchAPIKey != "" {
+ return &DuckDuckGoSearchProvider{
+ proxy: opts.Proxy,
+ client: client,
+ }, maxResults, nil
+ case "baidu_search":
+ if !opts.providerReady("baidu_search") {
+ return nil, 0, nil
+ }
client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout)
if err != nil {
- return nil, fmt.Errorf("failed to create HTTP client for Baidu Search: %w", err)
+ return nil, 0, fmt.Errorf("failed to create HTTP client for Baidu Search: %w", err)
}
- provider = &BaiduSearchProvider{
+ maxResults := 10
+ if opts.BaiduSearchMaxResults > 0 {
+ maxResults = min(opts.BaiduSearchMaxResults, 10)
+ }
+ return &BaiduSearchProvider{
apiKey: opts.BaiduSearchAPIKey,
baseURL: opts.BaiduSearchBaseURL,
proxy: opts.Proxy,
client: client,
+ }, maxResults, nil
+ case "glm_search":
+ if !opts.providerReady("glm_search") {
+ return nil, 0, nil
}
- if opts.BaiduSearchMaxResults > 0 {
- maxResults = min(opts.BaiduSearchMaxResults, 10)
- }
- } else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != "" {
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
if err != nil {
- return nil, fmt.Errorf("failed to create HTTP client for GLM Search: %w", err)
+ return nil, 0, fmt.Errorf("failed to create HTTP client for GLM Search: %w", err)
}
searchEngine := opts.GLMSearchEngine
if searchEngine == "" {
searchEngine = "search_std"
}
- provider = &GLMSearchProvider{
+ maxResults := 10
+ if opts.GLMSearchMaxResults > 0 {
+ maxResults = min(opts.GLMSearchMaxResults, 10)
+ }
+ return &GLMSearchProvider{
apiKey: opts.GLMSearchAPIKey,
baseURL: opts.GLMSearchBaseURL,
searchEngine: searchEngine,
proxy: opts.Proxy,
client: client,
+ }, maxResults, nil
+ default:
+ return nil, 0, fmt.Errorf("unknown web search provider %q", name)
+ }
+}
+
+func containsHan(text string) bool {
+ for _, r := range text {
+ if unicode.Is(unicode.Han, r) {
+ return true
}
- if opts.GLMSearchMaxResults > 0 {
- maxResults = min(opts.GLMSearchMaxResults, 10)
+ }
+ return false
+}
+
+func containsLatinLetter(text string) bool {
+ for _, r := range text {
+ if unicode.IsLetter(r) && unicode.In(r, unicode.Latin) {
+ return true
}
- } else {
+ }
+ return false
+}
+
+func prefersDuckDuckGoQuery(text string) bool {
+ trimmed := strings.TrimSpace(text)
+ if trimmed == "" {
+ return false
+ }
+ if containsHan(trimmed) {
+ return false
+ }
+ if containsLatinLetter(trimmed) {
+ return true
+ }
+ return false
+}
+
+func (opts WebSearchToolOptions) buildProviderResolver() (func(query string) (SearchProvider, int), error) {
+ providersByName := make(map[string]SearchProvider, len(knownWebSearchProviders))
+ maxResultsByName := make(map[string]int, len(knownWebSearchProviders))
+
+ for _, name := range knownWebSearchProviders {
+ if !opts.providerReady(name) {
+ continue
+ }
+ provider, maxResults, err := opts.providerByName(name)
+ if err != nil {
+ return nil, err
+ }
+ if provider == nil {
+ continue
+ }
+ providersByName[name] = provider
+ maxResultsByName[name] = maxResults
+ }
+
+ return func(query string) (SearchProvider, int) {
+ name, err := opts.resolveProviderName(query)
+ if err != nil {
+ return nil, 0
+ }
+ provider, ok := providersByName[name]
+ if !ok {
+ return nil, 0
+ }
+ return provider, maxResultsByName[name]
+ }, nil
+}
+
+func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
+ resolver, err := opts.buildProviderResolver()
+ if err != nil {
+ return nil, err
+ }
+ provider, maxResults := resolver("")
+ if provider == nil {
return nil, nil
}
return &WebSearchTool{
- provider: provider,
- maxResults: maxResults,
+ provider: provider,
+ maxResults: maxResults,
+ providerResolver: resolver,
}, nil
}
@@ -1053,13 +1496,22 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolR
}
query = strings.TrimSpace(query)
- count64, err := getInt64Arg(args, "count", int64(t.maxResults))
+ provider := t.provider
+ maxResults := t.maxResults
+ if t.providerResolver != nil {
+ provider, maxResults = t.providerResolver(query)
+ }
+ if provider == nil {
+ return ErrorResult("search provider is not configured")
+ }
+
+ count64, err := getInt64Arg(args, "count", int64(maxResults))
if err != nil {
return ErrorResult(err.Error())
}
- count := t.maxResults
+ count := maxResults
if count64 > 0 && count64 <= 10 {
- count = int(count64)
+ count = min(int(count64), maxResults)
}
rangeCode, err := normalizeSearchRange("")
@@ -1077,7 +1529,7 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolR
}
}
- result, err := t.provider.Search(ctx, query, count, rangeCode)
+ result, err := provider.Search(ctx, query, count, rangeCode)
if err != nil {
return ErrorResult(fmt.Sprintf("search failed: %v", err))
}
@@ -1102,6 +1554,8 @@ type privateHostWhitelist struct {
cidrs []*net.IPNet
}
+type webFetchAllowedFirstHopHostKey struct{}
+
func NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) {
// createHTTPClient cannot fail with an empty proxy string.
return NewWebFetchToolWithConfig(maxChars, "", format, fetchLimitBytes, nil)
@@ -1153,6 +1607,7 @@ func NewWebFetchToolWithConfig(
if isObviousPrivateHost(req.URL.Hostname(), whitelist) {
return fmt.Errorf("redirect target is private or local network host")
}
+ allowConfiguredProxyFirstHop(req, client.Transport)
return nil
}
if fetchLimitBytes <= 0 {
@@ -1232,6 +1687,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
if reqErr != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", reqErr)
}
+ allowConfiguredProxyFirstHop(req, t.client.Transport)
req.Header.Set("User-Agent", ua)
resp, doErr := t.client.Do(req)
if doErr != nil {
@@ -1434,6 +1890,9 @@ func newSafeDialContext(
if host == "" {
return nil, fmt.Errorf("empty target host")
}
+ if isAllowedFirstHopHost(ctx, host) {
+ return dialer.DialContext(ctx, network, address)
+ }
if ip := net.ParseIP(host); ip != nil {
if shouldBlockPrivateIP(ip, whitelist) {
@@ -1482,6 +1941,46 @@ func newSafeDialContext(
}
}
+func allowConfiguredProxyFirstHop(req *http.Request, rt http.RoundTripper) {
+ if req == nil {
+ return
+ }
+
+ transport, ok := rt.(*http.Transport)
+ if !ok || transport.Proxy == nil {
+ return
+ }
+
+ proxyURL, err := transport.Proxy(req)
+ if err != nil || proxyURL == nil {
+ return
+ }
+
+ host := normalizeAllowedFirstHopHost(proxyURL.Hostname())
+ if host == "" {
+ return
+ }
+
+ *req = *req.WithContext(context.WithValue(
+ req.Context(),
+ webFetchAllowedFirstHopHostKey{},
+ host,
+ ))
+}
+
+func isAllowedFirstHopHost(ctx context.Context, host string) bool {
+ allowed, _ := ctx.Value(webFetchAllowedFirstHopHostKey{}).(string)
+ if allowed == "" {
+ return false
+ }
+ return allowed == normalizeAllowedFirstHopHost(host)
+}
+
+func normalizeAllowedFirstHopHost(host string) string {
+ host = strings.ToLower(strings.TrimSpace(host))
+ return strings.TrimSuffix(host, ".")
+}
+
func newPrivateHostWhitelist(entries []string) (*privateHostWhitelist, error) {
if len(entries) == 0 {
return nil, nil
diff --git a/pkg/tools/web_test.go b/pkg/tools/integration/web_test.go
similarity index 82%
rename from pkg/tools/web_test.go
rename to pkg/tools/integration/web_test.go
index de6187cfa..ba6b3da45 100644
--- a/pkg/tools/web_test.go
+++ b/pkg/tools/integration/web_test.go
@@ -1,4 +1,4 @@
-package tools
+package integrationtools
import (
"bytes"
@@ -385,14 +385,14 @@ func TestWebFetchTool_PayloadTooLarge(t *testing.T) {
}
}
-// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing
+// TestWebTool_WebSearch_NoApiKey verifies providers without required credentials are not registered.
func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKeys: nil})
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if tool != nil {
- t.Errorf("Expected nil tool when Brave API key is empty")
+ t.Fatalf("Expected nil tool when only enabled provider is missing credentials")
}
// Also nil when nothing is enabled
@@ -757,6 +757,33 @@ func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) {
}
}
+func TestWebTool_WebFetch_AllowsLoopbackProxy(t *testing.T) {
+ proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.String() != "http://example.com/proxied" {
+ t.Fatalf("proxy received URL %q, want %q", r.URL.String(), "http://example.com/proxied")
+ }
+ w.Header().Set("Content-Type", "text/plain")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("proxied content"))
+ }))
+ defer proxy.Close()
+
+ tool, err := NewWebFetchToolWithProxy(50000, proxy.URL, format, testFetchLimit, nil)
+ if err != nil {
+ t.Fatalf("Failed to create web fetch tool: %v", err)
+ }
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "url": "http://example.com/proxied",
+ })
+ if result.IsError {
+ t.Fatalf("expected success through loopback proxy, got %q", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "proxied content") {
+ t.Fatalf("expected proxied content, got %q", result.ForLLM)
+ }
+}
+
// TestWebFetch_BlocksIPv4MappedIPv6Loopback verifies ::ffff:127.0.0.1 is blocked
func TestWebFetch_BlocksIPv4MappedIPv6Loopback(t *testing.T) {
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
@@ -1082,6 +1109,40 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
}
})
+
+ t.Run("searxng", func(t *testing.T) {
+ tool, err := NewWebSearchTool(WebSearchToolOptions{
+ SearXNGEnabled: true,
+ SearXNGBaseURL: "https://searx.example.com",
+ SearXNGMaxResults: 3,
+ Proxy: "http://127.0.0.1:7890",
+ })
+ if err != nil {
+ t.Fatalf("NewWebSearchTool() error: %v", err)
+ }
+ p, ok := tool.provider.(*SearXNGSearchProvider)
+ if !ok {
+ t.Fatalf("provider type = %T, want *SearXNGSearchProvider", tool.provider)
+ }
+ if p.proxy != "http://127.0.0.1:7890" {
+ t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
+ }
+ tr, ok := p.client.Transport.(*http.Transport)
+ if !ok {
+ t.Fatalf("client.Transport type = %T, want *http.Transport", p.client.Transport)
+ }
+ req, err := http.NewRequest(http.MethodGet, "https://searx.example.com/search", nil)
+ if err != nil {
+ t.Fatalf("http.NewRequest() error: %v", err)
+ }
+ proxyURL, err := tr.Proxy(req)
+ if err != nil {
+ t.Fatalf("transport.Proxy(req) error: %v", err)
+ }
+ if proxyURL == nil || proxyURL.String() != "http://127.0.0.1:7890" {
+ t.Fatalf("proxy URL = %v, want %q", proxyURL, "http://127.0.0.1:7890")
+ }
+ })
}
// TestWebTool_TavilySearch_Success verifies successful Tavily search
@@ -1667,3 +1728,270 @@ func TestWebTool_GLMSearch_Priority(t *testing.T) {
t.Errorf("Expected GLMSearchProvider when only GLM enabled, got %T", tool2.provider)
}
}
+
+func TestWebTool_SogouSearch_Success(t *testing.T) {
+ provider := &SogouSearchProvider{
+ client: &http.Client{
+ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ rec := httptest.NewRecorder()
+ fmt.Fprint(rec, `
+Result A
+Snippet A
+Result B
+Snippet B
+`)
+ return rec.Result(), nil
+ }),
+ },
+ }
+
+ out, err := provider.Search(context.Background(), "test query", 2, "")
+ if err != nil {
+ t.Fatalf("Search() error: %v", err)
+ }
+ if !strings.Contains(out, "via Sogou") || !strings.Contains(out, "https://example.com/a") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestApplySogouRangeHint(t *testing.T) {
+ tests := []struct {
+ name string
+ query string
+ rangeCode string
+ want string
+ }{
+ {name: "empty range", query: "golang", rangeCode: "", want: "golang"},
+ {name: "day", query: "golang", rangeCode: "d", want: "golang 最近一天"},
+ {name: "week", query: "golang", rangeCode: "w", want: "golang 最近一周"},
+ {name: "month", query: "golang", rangeCode: "m", want: "golang 最近一个月"},
+ {name: "year", query: "golang", rangeCode: "y", want: "golang 最近一年"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := applySogouRangeHint(tt.query, tt.rangeCode); got != tt.want {
+ t.Fatalf("applySogouRangeHint(%q, %q) = %q, want %q", tt.query, tt.rangeCode, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestPrefersDuckDuckGoQuery(t *testing.T) {
+ tests := []struct {
+ name string
+ query string
+ want bool
+ }{
+ {name: "english words", query: "golang web search", want: true},
+ {name: "english with numbers", query: "OpenAI o3 price 2026", want: true},
+ {name: "chinese", query: "今天上海天气", want: false},
+ {name: "mixed with han", query: "golang 中文 教程", want: false},
+ {name: "numbers only", query: "2026 04 15", want: false},
+ {name: "blank", query: " ", want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := prefersDuckDuckGoQuery(tt.query); got != tt.want {
+ t.Fatalf("prefersDuckDuckGoQuery(%q) = %v, want %v", tt.query, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestPrefersDuckDuckGoQuery_DoesNotUseGlobalLanguageFallback(t *testing.T) {
+ if prefersDuckDuckGoQuery("2026 04 15") {
+ t.Fatal("numeric query should default to Sogou when no script-specific hint is present")
+ }
+}
+
+func TestWebTool_SogouPriorityAndExplicitProvider(t *testing.T) {
+ tool, err := NewWebSearchTool(WebSearchToolOptions{
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ DuckDuckGoEnabled: true,
+ DuckDuckGoMaxResults: 5,
+ })
+ if err != nil {
+ t.Fatalf("NewWebSearchTool() error: %v", err)
+ }
+ if _, ok := tool.provider.(*SogouSearchProvider); !ok {
+ t.Fatalf("expected SogouSearchProvider, got %T", tool.provider)
+ }
+
+ tool, err = NewWebSearchTool(WebSearchToolOptions{
+ Provider: "duckduckgo",
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ DuckDuckGoEnabled: true,
+ DuckDuckGoMaxResults: 5,
+ })
+ if err != nil {
+ t.Fatalf("NewWebSearchTool() error: %v", err)
+ }
+ if _, ok := tool.provider.(*DuckDuckGoSearchProvider); !ok {
+ t.Fatalf("expected DuckDuckGoSearchProvider, got %T", tool.provider)
+ }
+}
+
+func TestWebTool_AutoProviderPrefersConfiguredProvidersBeforeSogou(t *testing.T) {
+ tool, err := NewWebSearchTool(WebSearchToolOptions{
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ BraveEnabled: true,
+ BraveAPIKeys: []string{"brave-key"},
+ BraveMaxResults: 5,
+ DuckDuckGoEnabled: true,
+ DuckDuckGoMaxResults: 5,
+ })
+ if err != nil {
+ t.Fatalf("NewWebSearchTool() error: %v", err)
+ }
+ if _, ok := tool.provider.(*BraveSearchProvider); !ok {
+ t.Fatalf("expected BraveSearchProvider, got %T", tool.provider)
+ }
+}
+
+func TestWebTool_ExplicitProviderFallsBackWhenMissingCredentials(t *testing.T) {
+ tool, err := NewWebSearchTool(WebSearchToolOptions{
+ Provider: "brave",
+ BraveEnabled: true,
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ })
+ if err != nil {
+ t.Fatalf("NewWebSearchTool() error: %v", err)
+ }
+ if _, ok := tool.provider.(*SogouSearchProvider); !ok {
+ t.Fatalf("expected SogouSearchProvider after fallback, got %T", tool.provider)
+ }
+}
+
+func TestWebTool_ExplicitProviderFallsBackWhenMissingBaseURL(t *testing.T) {
+ tool, err := NewWebSearchTool(WebSearchToolOptions{
+ Provider: "searxng",
+ SearXNGEnabled: true,
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ })
+ if err != nil {
+ t.Fatalf("NewWebSearchTool() error: %v", err)
+ }
+ if _, ok := tool.provider.(*SogouSearchProvider); !ok {
+ t.Fatalf("expected SogouSearchProvider after fallback, got %T", tool.provider)
+ }
+}
+
+func TestWebTool_AutoProviderSkipsEnabledButUnreadyProviders(t *testing.T) {
+ tool, err := NewWebSearchTool(WebSearchToolOptions{
+ Provider: "auto",
+ BraveEnabled: true,
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ })
+ if err != nil {
+ t.Fatalf("NewWebSearchTool() error: %v", err)
+ }
+ if _, ok := tool.provider.(*SogouSearchProvider); !ok {
+ t.Fatalf("expected SogouSearchProvider when Brave has no API key, got %T", tool.provider)
+ }
+}
+
+func TestResolveWebSearchProviderName_FallsBackFromExplicitUnavailableProvider(t *testing.T) {
+ got, err := ResolveWebSearchProviderName(WebSearchToolOptions{
+ Provider: "brave",
+ BraveEnabled: true,
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ }, "")
+ if err != nil {
+ t.Fatalf("ResolveWebSearchProviderName() error: %v", err)
+ }
+ if got != "sogou" {
+ t.Fatalf("ResolveWebSearchProviderName() = %q, want sogou", got)
+ }
+}
+
+func TestWebTool_UnknownExplicitProviderFallsBackToAuto(t *testing.T) {
+ tool, err := NewWebSearchTool(WebSearchToolOptions{
+ Provider: "totally_unknown",
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ })
+ if err != nil {
+ t.Fatalf("NewWebSearchTool() error: %v", err)
+ }
+ if _, ok := tool.provider.(*SogouSearchProvider); !ok {
+ t.Fatalf("expected SogouSearchProvider after fallback, got %T", tool.provider)
+ }
+}
+
+func TestResolveWebSearchProviderName_FallsBackFromUnknownProvider(t *testing.T) {
+ got, err := ResolveWebSearchProviderName(WebSearchToolOptions{
+ Provider: "totally_unknown",
+ SogouEnabled: true,
+ SogouMaxResults: 5,
+ }, "")
+ if err != nil {
+ t.Fatalf("ResolveWebSearchProviderName() error: %v", err)
+ }
+ if got != "sogou" {
+ t.Fatalf("ResolveWebSearchProviderName() = %q, want sogou", got)
+ }
+}
+
+type stubSearchProvider struct {
+ result string
+ calls []string
+}
+
+func (p *stubSearchProvider) Search(
+ _ context.Context,
+ query string,
+ _ int,
+ _ string,
+) (string, error) {
+ p.calls = append(p.calls, query)
+ return p.result, nil
+}
+
+func TestWebTool_AutoProviderRoutesQueryLanguageBetweenSogouAndDuckDuckGo(t *testing.T) {
+ sogouProvider := &stubSearchProvider{result: "via sogou"}
+ duckProvider := &stubSearchProvider{result: "via duckduckgo"}
+ tool := &WebSearchTool{
+ provider: sogouProvider,
+ maxResults: 5,
+ providerResolver: func(query string) (SearchProvider, int) {
+ if prefersDuckDuckGoQuery(query) {
+ return duckProvider, 3
+ }
+ return sogouProvider, 5
+ },
+ }
+
+ enResult := tool.Execute(context.Background(), map[string]any{"query": "golang concurrency", "count": 10})
+ if enResult.IsError {
+ t.Fatalf("english Execute() returned error: %s", enResult.ForLLM)
+ }
+ if len(duckProvider.calls) != 1 || duckProvider.calls[0] != "golang concurrency" {
+ t.Fatalf("english query should use DuckDuckGo provider, calls=%v", duckProvider.calls)
+ }
+ if len(sogouProvider.calls) != 0 {
+ t.Fatalf("english query should not call Sogou provider, calls=%v", sogouProvider.calls)
+ }
+
+ zhResult := tool.Execute(context.Background(), map[string]any{"query": "今天上海天气"})
+ if zhResult.IsError {
+ t.Fatalf("chinese Execute() returned error: %s", zhResult.ForLLM)
+ }
+ if len(sogouProvider.calls) != 1 || sogouProvider.calls[0] != "今天上海天气" {
+ t.Fatalf("chinese query should use Sogou provider, calls=%v", sogouProvider.calls)
+ }
+}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return fn(req)
+}
diff --git a/pkg/tools/integration_facade.go b/pkg/tools/integration_facade.go
new file mode 100644
index 000000000..193ecd6f5
--- /dev/null
+++ b/pkg/tools/integration_facade.go
@@ -0,0 +1,106 @@
+package tools
+
+import (
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+
+ "github.com/sipeed/picoclaw/pkg/audio/tts"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/media"
+ "github.com/sipeed/picoclaw/pkg/skills"
+ integrationtools "github.com/sipeed/picoclaw/pkg/tools/integration"
+)
+
+type (
+ SendCallbackWithContext = integrationtools.SendCallbackWithContext
+ ReactionCallback = integrationtools.ReactionCallback
+ MCPManager = integrationtools.MCPManager
+ MCPTool = integrationtools.MCPTool
+ FindSkillsTool = integrationtools.FindSkillsTool
+ InstallSkillTool = integrationtools.InstallSkillTool
+ MessageTool = integrationtools.MessageTool
+ ReactionTool = integrationtools.ReactionTool
+ SendTTSTool = integrationtools.SendTTSTool
+ APIKeyPool = integrationtools.APIKeyPool
+ APIKeyIterator = integrationtools.APIKeyIterator
+ SearchProvider = integrationtools.SearchProvider
+ SearchResultItem = integrationtools.SearchResultItem
+ BraveSearchProvider = integrationtools.BraveSearchProvider
+ TavilySearchProvider = integrationtools.TavilySearchProvider
+ SogouSearchProvider = integrationtools.SogouSearchProvider
+ DuckDuckGoSearchProvider = integrationtools.DuckDuckGoSearchProvider
+ PerplexitySearchProvider = integrationtools.PerplexitySearchProvider
+ SearXNGSearchProvider = integrationtools.SearXNGSearchProvider
+ GLMSearchProvider = integrationtools.GLMSearchProvider
+ BaiduSearchProvider = integrationtools.BaiduSearchProvider
+ WebSearchTool = integrationtools.WebSearchTool
+ WebSearchToolOptions = integrationtools.WebSearchToolOptions
+ WebFetchTool = integrationtools.WebFetchTool
+)
+
+func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool {
+ return integrationtools.NewMCPTool(manager, serverName, tool)
+}
+
+func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool {
+ return integrationtools.NewFindSkillsTool(registryMgr, cache)
+}
+
+func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool {
+ return integrationtools.NewInstallSkillTool(registryMgr, workspace)
+}
+
+func NewMessageTool() *MessageTool {
+ return integrationtools.NewMessageTool()
+}
+
+func NewReactionTool() *ReactionTool {
+ return integrationtools.NewReactionTool()
+}
+
+func NewSendTTSTool(provider tts.TTSProvider, store media.MediaStore) *SendTTSTool {
+ return integrationtools.NewSendTTSTool(provider, store)
+}
+
+func NewAPIKeyPool(keys []string) *APIKeyPool {
+ return integrationtools.NewAPIKeyPool(keys)
+}
+
+func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions {
+ return integrationtools.WebSearchToolOptionsFromConfig(cfg)
+}
+
+func WebSearchProviderReady(opts WebSearchToolOptions, name string) bool {
+ return integrationtools.WebSearchProviderReady(opts, name)
+}
+
+func ResolveWebSearchProviderName(opts WebSearchToolOptions, query string) (string, error) {
+ return integrationtools.ResolveWebSearchProviderName(opts, query)
+}
+
+func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
+ return integrationtools.NewWebSearchTool(opts)
+}
+
+func NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) {
+ return integrationtools.NewWebFetchTool(maxChars, format, fetchLimitBytes)
+}
+
+func NewWebFetchToolWithProxy(
+ maxChars int,
+ proxy string,
+ format string,
+ fetchLimitBytes int64,
+ privateHostWhitelist []string,
+) (*WebFetchTool, error) {
+ return integrationtools.NewWebFetchToolWithProxy(maxChars, proxy, format, fetchLimitBytes, privateHostWhitelist)
+}
+
+func NewWebFetchToolWithConfig(
+ maxChars int,
+ proxy string,
+ format string,
+ fetchLimitBytes int64,
+ privateHostWhitelist []string,
+) (*WebFetchTool, error) {
+ return integrationtools.NewWebFetchToolWithConfig(maxChars, proxy, format, fetchLimitBytes, privateHostWhitelist)
+}
diff --git a/pkg/tools/load_image_compat_test.go b/pkg/tools/load_image_compat_test.go
new file mode 100644
index 000000000..a29ee2042
--- /dev/null
+++ b/pkg/tools/load_image_compat_test.go
@@ -0,0 +1,29 @@
+package tools
+
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func TestSubagentManager_SetMediaResolver_StoresResolver(t *testing.T) {
+ manager := NewSubagentManager(nil, "gpt-test", "/tmp")
+
+ called := false
+ manager.SetMediaResolver(func(msgs []providers.Message) []providers.Message {
+ called = true
+ return msgs
+ })
+
+ manager.mu.RLock()
+ got := manager.mediaResolver
+ manager.mu.RUnlock()
+
+ if got == nil {
+ t.Fatal("expected mediaResolver to be set")
+ }
+
+ if called {
+ t.Fatal("resolver should not be called during SetMediaResolver")
+ }
+}
diff --git a/pkg/tools/message.go b/pkg/tools/message.go
deleted file mode 100644
index 438ceeddd..000000000
--- a/pkg/tools/message.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package tools
-
-import (
- "context"
- "fmt"
- "sync/atomic"
-)
-
-type SendCallback func(channel, chatID, content string) error
-
-type MessageTool struct {
- sendCallback SendCallback
- sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round
-}
-
-func NewMessageTool() *MessageTool {
- return &MessageTool{}
-}
-
-func (t *MessageTool) Name() string {
- return "message"
-}
-
-func (t *MessageTool) Description() string {
- return "Send a message to user on a chat channel. Use this when you want to communicate something."
-}
-
-func (t *MessageTool) Parameters() map[string]any {
- return map[string]any{
- "type": "object",
- "properties": map[string]any{
- "content": map[string]any{
- "type": "string",
- "description": "The message content to send",
- },
- "channel": map[string]any{
- "type": "string",
- "description": "Optional: target channel (telegram, whatsapp, etc.)",
- },
- "chat_id": map[string]any{
- "type": "string",
- "description": "Optional: target chat/user ID",
- },
- },
- "required": []string{"content"},
- }
-}
-
-// ResetSentInRound resets the per-round send tracker.
-// Called by the agent loop at the start of each inbound message processing round.
-func (t *MessageTool) ResetSentInRound() {
- t.sentInRound.Store(false)
-}
-
-// HasSentInRound returns true if the message tool sent a message during the current round.
-func (t *MessageTool) HasSentInRound() bool {
- return t.sentInRound.Load()
-}
-
-func (t *MessageTool) SetSendCallback(callback SendCallback) {
- t.sendCallback = callback
-}
-
-func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
- content, ok := args["content"].(string)
- if !ok {
- return &ToolResult{ForLLM: "content is required", IsError: true}
- }
-
- channel, _ := args["channel"].(string)
- chatID, _ := args["chat_id"].(string)
-
- if channel == "" {
- channel = ToolChannel(ctx)
- }
- if chatID == "" {
- chatID = ToolChatID(ctx)
- }
-
- if channel == "" || chatID == "" {
- return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true}
- }
-
- if t.sendCallback == nil {
- return &ToolResult{ForLLM: "Message sending not configured", IsError: true}
- }
-
- if err := t.sendCallback(channel, chatID, content); err != nil {
- return &ToolResult{
- ForLLM: fmt.Sprintf("sending message: %v", err),
- IsError: true,
- Err: err,
- }
- }
-
- t.sentInRound.Store(true)
- // Silent: user already received the message directly
- return &ToolResult{
- ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),
- Silent: true,
- }
-}
diff --git a/pkg/tools/path_compat.go b/pkg/tools/path_compat.go
new file mode 100644
index 000000000..9e677cb2b
--- /dev/null
+++ b/pkg/tools/path_compat.go
@@ -0,0 +1,19 @@
+package tools
+
+import (
+ "regexp"
+
+ fstools "github.com/sipeed/picoclaw/pkg/tools/fs"
+)
+
+func validatePathWithAllowPaths(
+ path, workspace string,
+ restrict bool,
+ patterns []*regexp.Regexp,
+) (string, error) {
+ return fstools.ValidatePathWithAllowPaths(path, workspace, restrict, patterns)
+}
+
+func isAllowedPath(path string, patterns []*regexp.Regexp) bool {
+ return fstools.IsAllowedPath(path, patterns)
+}
diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go
index 1e6263dc8..a68746b82 100644
--- a/pkg/tools/registry.go
+++ b/pkg/tools/registry.go
@@ -278,6 +278,7 @@ func (r *ToolRegistry) ExecuteWithContext(
func() {
defer func() {
if re := recover(); re != nil {
+ logger.RecoverPanicNoExit(re)
errMsg := fmt.Sprintf("Tool '%s' crashed with panic: %v", name, re)
logger.ErrorCF("tool", "Tool execution panic recovered",
map[string]any{
@@ -401,6 +402,7 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
name, _ := fn["name"].(string)
desc, _ := fn["description"].(string)
params, _ := fn["parameters"].(map[string]any)
+ metadata := promptMetadataForTool(entry.Tool)
definitions = append(definitions, providers.ToolDefinition{
Type: "function",
@@ -409,11 +411,35 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
Description: desc,
Parameters: params,
},
+ PromptLayer: metadata.Layer,
+ PromptSlot: metadata.Slot,
+ PromptSource: metadata.Source,
})
}
return definitions
}
+func promptMetadataForTool(tool Tool) PromptMetadata {
+ metadata := PromptMetadata{
+ Layer: ToolPromptLayerCapability,
+ Slot: ToolPromptSlotTooling,
+ Source: ToolPromptSourceRegistry,
+ }
+ if provider, ok := tool.(PromptMetadataProvider); ok {
+ provided := provider.PromptMetadata()
+ if provided.Layer != "" {
+ metadata.Layer = provided.Layer
+ }
+ if provided.Slot != "" {
+ metadata.Slot = provided.Slot
+ }
+ if provided.Source != "" {
+ metadata.Source = provided.Source
+ }
+ }
+ return metadata
+}
+
// List returns a list of all registered tool names.
func (r *ToolRegistry) List() []string {
r.mu.RLock()
diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go
index 2633411ff..5ce79e227 100644
--- a/pkg/tools/registry_test.go
+++ b/pkg/tools/registry_test.go
@@ -39,6 +39,15 @@ func (m *mockContextAwareTool) Execute(ctx context.Context, _ map[string]any) *T
return m.result
}
+type mockPromptMetadataTool struct {
+ mockRegistryTool
+ metadata PromptMetadata
+}
+
+func (m *mockPromptMetadataTool) PromptMetadata() PromptMetadata {
+ return m.metadata
+}
+
type mockAsyncRegistryTool struct {
mockRegistryTool
lastCB AsyncCallback
@@ -216,6 +225,33 @@ func TestToolRegistry_ExecuteWithContext_EmptyContext(t *testing.T) {
}
}
+func TestToolRegistry_ExecuteWithContext_PreservesMessageContext(t *testing.T) {
+ r := NewToolRegistry()
+ ct := &mockContextAwareTool{
+ mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
+ }
+ r.Register(ct)
+
+ baseCtx := WithToolMessageContext(context.Background(), "msg-123", "msg-100")
+ r.ExecuteWithContext(baseCtx, "ctx_tool", nil, "telegram", "chat-42", nil)
+
+ if ct.lastCtx == nil {
+ t.Fatal("expected Execute to be called")
+ }
+ if got := ToolChannel(ct.lastCtx); got != "telegram" {
+ t.Errorf("expected channel 'telegram', got %q", got)
+ }
+ if got := ToolChatID(ct.lastCtx); got != "chat-42" {
+ t.Errorf("expected chatID 'chat-42', got %q", got)
+ }
+ if got := ToolMessageID(ct.lastCtx); got != "msg-123" {
+ t.Errorf("expected messageID 'msg-123', got %q", got)
+ }
+ if got := ToolReplyToMessageID(ct.lastCtx); got != "msg-100" {
+ t.Errorf("expected replyToMessageID 'msg-100', got %q", got)
+ }
+}
+
func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
r := NewToolRegistry()
at := &mockAsyncRegistryTool{
@@ -378,6 +414,47 @@ func TestToolToSchema(t *testing.T) {
}
}
+func TestToolRegistry_ToProviderDefsAttachesPromptMetadata(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("native", "native tool"))
+ r.Register(&mockPromptMetadataTool{
+ mockRegistryTool: mockRegistryTool{
+ name: "mcp_demo",
+ desc: "mcp tool",
+ params: map[string]any{"type": "object"},
+ },
+ metadata: PromptMetadata{
+ Layer: ToolPromptLayerCapability,
+ Slot: ToolPromptSlotMCP,
+ Source: "mcp:demo",
+ },
+ })
+
+ defs := r.ToProviderDefs()
+ if len(defs) != 2 {
+ t.Fatalf("ToProviderDefs() len = %d, want 2", len(defs))
+ }
+
+ byName := make(map[string]providers.ToolDefinition, len(defs))
+ for _, def := range defs {
+ byName[def.Function.Name] = def
+ }
+
+ native := byName["native"]
+ if native.PromptLayer != ToolPromptLayerCapability ||
+ native.PromptSlot != ToolPromptSlotTooling ||
+ native.PromptSource != ToolPromptSourceRegistry {
+ t.Fatalf("native prompt metadata = %#v, want default tooling source", native)
+ }
+
+ mcp := byName["mcp_demo"]
+ if mcp.PromptLayer != ToolPromptLayerCapability ||
+ mcp.PromptSlot != ToolPromptSlotMCP ||
+ mcp.PromptSource != "mcp:demo" {
+ t.Fatalf("mcp prompt metadata = %#v, want mcp source", mcp)
+ }
+}
+
func TestToolRegistry_Clone(t *testing.T) {
r := NewToolRegistry()
r.Register(newMockTool("read_file", "reads files"))
diff --git a/pkg/tools/search_tool.go b/pkg/tools/search_tool.go
index f41c80d90..c5884c9de 100644
--- a/pkg/tools/search_tool.go
+++ b/pkg/tools/search_tool.go
@@ -34,6 +34,14 @@ func (t *RegexSearchTool) Description() string {
return "Search available hidden tools on-demand using a regex pattern. Returns JSON schemas of discovered tools."
}
+func (t *RegexSearchTool) PromptMetadata() PromptMetadata {
+ return PromptMetadata{
+ Layer: ToolPromptLayerCapability,
+ Slot: ToolPromptSlotTooling,
+ Source: ToolPromptSourceDiscovery,
+ }
+}
+
func (t *RegexSearchTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
@@ -95,6 +103,14 @@ func (t *BM25SearchTool) Description() string {
return "Search available hidden tools on-demand using natural language query describing the action you need to perform. Returns JSON schemas of discovered tools."
}
+func (t *BM25SearchTool) PromptMetadata() PromptMetadata {
+ return PromptMetadata{
+ Layer: ToolPromptLayerCapability,
+ Slot: ToolPromptSlotTooling,
+ Source: ToolPromptSourceDiscovery,
+ }
+}
+
func (t *BM25SearchTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
diff --git a/pkg/tools/session.go b/pkg/tools/session.go
index 141dd4b5e..8c7584254 100644
--- a/pkg/tools/session.go
+++ b/pkg/tools/session.go
@@ -242,11 +242,3 @@ func (sm *SessionManager) List() []SessionInfo {
func generateSessionID() string {
return uuid.New().String()[:8]
}
-
-type SessionInfo struct {
- ID string `json:"id"`
- Command string `json:"command"`
- Status string `json:"status"`
- PID int `json:"pid"`
- StartedAt int64 `json:"startedAt"`
-}
diff --git a/pkg/tools/base.go b/pkg/tools/shared/base.go
similarity index 51%
rename from pkg/tools/base.go
rename to pkg/tools/shared/base.go
index ec743e164..298e1b478 100644
--- a/pkg/tools/base.go
+++ b/pkg/tools/shared/base.go
@@ -1,6 +1,10 @@
-package tools
+package toolshared
-import "context"
+import (
+ "context"
+
+ "github.com/sipeed/picoclaw/pkg/session"
+)
// Tool is the interface that all tools must implement.
type Tool interface {
@@ -10,6 +14,24 @@ type Tool interface {
Execute(ctx context.Context, args map[string]any) *ToolResult
}
+const (
+ ToolPromptLayerCapability = "capability"
+ ToolPromptSlotTooling = "tooling"
+ ToolPromptSlotMCP = "mcp"
+ ToolPromptSourceRegistry = "tool_registry:native"
+ ToolPromptSourceDiscovery = "tool_registry:discovery"
+)
+
+type PromptMetadata struct {
+ Layer string
+ Slot string
+ Source string
+}
+
+type PromptMetadataProvider interface {
+ PromptMetadata() PromptMetadata
+}
+
// --- Request-scoped tool context (channel / chatID) ---
//
// Carried via context.Value so that concurrent tool calls each receive
@@ -21,8 +43,13 @@ type Tool interface {
type toolCtxKey struct{ name string }
var (
- ctxKeyChannel = &toolCtxKey{"channel"}
- ctxKeyChatID = &toolCtxKey{"chatID"}
+ ctxKeyChannel = &toolCtxKey{"channel"}
+ ctxKeyChatID = &toolCtxKey{"chatID"}
+ ctxKeyMessageID = &toolCtxKey{"messageID"}
+ ctxKeyReplyToMessageID = &toolCtxKey{"replyToMessageID"}
+ ctxKeyAgentID = &toolCtxKey{"agentID"}
+ ctxKeySessionKey = &toolCtxKey{"sessionKey"}
+ ctxKeySessionScope = &toolCtxKey{"sessionScope"}
)
// WithToolContext returns a child context carrying channel and chatID.
@@ -32,6 +59,35 @@ func WithToolContext(ctx context.Context, channel, chatID string) context.Contex
return ctx
}
+// WithToolMessageContext returns a child context carrying inbound message IDs.
+func WithToolMessageContext(ctx context.Context, messageID, replyToMessageID string) context.Context {
+ ctx = context.WithValue(ctx, ctxKeyMessageID, messageID)
+ ctx = context.WithValue(ctx, ctxKeyReplyToMessageID, replyToMessageID)
+ return ctx
+}
+
+// WithToolInboundContext returns a child context carrying channel/chat and inbound IDs.
+func WithToolInboundContext(
+ ctx context.Context,
+ channel, chatID, messageID, replyToMessageID string,
+) context.Context {
+ ctx = WithToolContext(ctx, channel, chatID)
+ ctx = WithToolMessageContext(ctx, messageID, replyToMessageID)
+ return ctx
+}
+
+// WithToolSessionContext returns a child context carrying turn-scoped session metadata.
+func WithToolSessionContext(
+ ctx context.Context,
+ agentID, sessionKey string,
+ scope *session.SessionScope,
+) context.Context {
+ ctx = context.WithValue(ctx, ctxKeyAgentID, agentID)
+ ctx = context.WithValue(ctx, ctxKeySessionKey, sessionKey)
+ ctx = context.WithValue(ctx, ctxKeySessionScope, session.CloneScope(scope))
+ return ctx
+}
+
// ToolChannel extracts the channel from ctx, or "" if unset.
func ToolChannel(ctx context.Context) string {
v, _ := ctx.Value(ctxKeyChannel).(string)
@@ -44,6 +100,36 @@ func ToolChatID(ctx context.Context) string {
return v
}
+// ToolMessageID extracts the current inbound message ID from ctx, or "" if unset.
+func ToolMessageID(ctx context.Context) string {
+ v, _ := ctx.Value(ctxKeyMessageID).(string)
+ return v
+}
+
+// ToolReplyToMessageID extracts the current inbound reply target from ctx, or "" if unset.
+func ToolReplyToMessageID(ctx context.Context) string {
+ v, _ := ctx.Value(ctxKeyReplyToMessageID).(string)
+ return v
+}
+
+// ToolAgentID extracts the active turn's agent ID from ctx, or "" if unset.
+func ToolAgentID(ctx context.Context) string {
+ v, _ := ctx.Value(ctxKeyAgentID).(string)
+ return v
+}
+
+// ToolSessionKey extracts the active turn's session key from ctx, or "" if unset.
+func ToolSessionKey(ctx context.Context) string {
+ v, _ := ctx.Value(ctxKeySessionKey).(string)
+ return v
+}
+
+// ToolSessionScope extracts the active turn's structured session scope from ctx.
+func ToolSessionScope(ctx context.Context) *session.SessionScope {
+ scope, _ := ctx.Value(ctxKeySessionScope).(*session.SessionScope)
+ return session.CloneScope(scope)
+}
+
// AsyncCallback is a function type that async tools use to notify completion.
// When an async tool finishes its work, it calls this callback with the result.
//
diff --git a/pkg/tools/result.go b/pkg/tools/shared/result.go
similarity index 95%
rename from pkg/tools/result.go
rename to pkg/tools/shared/result.go
index c81213125..e4b16f7b3 100644
--- a/pkg/tools/result.go
+++ b/pkg/tools/shared/result.go
@@ -1,4 +1,4 @@
-package tools
+package toolshared
import (
"encoding/json"
@@ -8,8 +8,8 @@ import (
)
const (
- handledToolLLMNote = "The requested output has already been delivered to the user in the current chat. Do not call send_file or any other delivery tool again. If you reply, provide only a brief confirmation."
- artifactPathsLLMNote = "Use `send_file` with one of these paths to send it to the user, or use file/exec tools to save it inside the workspace if requested."
+ HandledToolLLMNote = "The requested output has already been delivered to the user in the current chat. Do not call send_file or any other delivery tool again. If you reply, provide only a brief confirmation."
+ ArtifactPathsLLMNote = "Use `send_file` with one of these paths to send it to the user, or use file/exec tools to save it inside the workspace if requested."
)
// ToolResult represents the structured return value from tool execution.
@@ -73,14 +73,14 @@ func (tr *ToolResult) ContentForLLM() string {
}
if tr.ResponseHandled {
if content == "" {
- return handledToolLLMNote
+ return HandledToolLLMNote
}
- if !strings.Contains(content, handledToolLLMNote) {
- content += "\n" + handledToolLLMNote
+ if !strings.Contains(content, HandledToolLLMNote) {
+ content += "\n" + HandledToolLLMNote
}
}
if len(tr.ArtifactTags) > 0 {
- artifactNote := "Local artifact paths: " + strings.Join(tr.ArtifactTags, " ") + "\n" + artifactPathsLLMNote
+ artifactNote := "Local artifact paths: " + strings.Join(tr.ArtifactTags, " ") + "\n" + ArtifactPathsLLMNote
if content == "" {
content = artifactNote
} else if !strings.Contains(content, artifactNote) {
diff --git a/pkg/tools/types.go b/pkg/tools/shared/types.go
similarity index 91%
rename from pkg/tools/types.go
rename to pkg/tools/shared/types.go
index 4d1a18d5a..8a74d30f3 100644
--- a/pkg/tools/types.go
+++ b/pkg/tools/shared/types.go
@@ -1,4 +1,4 @@
-package tools
+package toolshared
import "context"
@@ -77,3 +77,11 @@ type ExecResponse struct {
Error string `json:"error,omitempty"`
Sessions []SessionInfo `json:"sessions,omitempty"`
}
+
+type SessionInfo struct {
+ ID string `json:"id"`
+ Command string `json:"command"`
+ Status string `json:"status"`
+ PID int `json:"pid"`
+ StartedAt int64 `json:"startedAt"`
+}
diff --git a/pkg/tools/shared_facade.go b/pkg/tools/shared_facade.go
new file mode 100644
index 000000000..8409ea060
--- /dev/null
+++ b/pkg/tools/shared_facade.go
@@ -0,0 +1,118 @@
+package tools
+
+import (
+ "context"
+
+ "github.com/sipeed/picoclaw/pkg/session"
+ toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
+)
+
+type (
+ Message = toolshared.Message
+ ToolCall = toolshared.ToolCall
+ FunctionCall = toolshared.FunctionCall
+ LLMResponse = toolshared.LLMResponse
+ UsageInfo = toolshared.UsageInfo
+ LLMProvider = toolshared.LLMProvider
+ ToolDefinition = toolshared.ToolDefinition
+ ToolFunctionDefinition = toolshared.ToolFunctionDefinition
+ ExecRequest = toolshared.ExecRequest
+ ExecResponse = toolshared.ExecResponse
+ SessionInfo = toolshared.SessionInfo
+ Tool = toolshared.Tool
+ AsyncCallback = toolshared.AsyncCallback
+ AsyncExecutor = toolshared.AsyncExecutor
+ PromptMetadata = toolshared.PromptMetadata
+ PromptMetadataProvider = toolshared.PromptMetadataProvider
+ ToolResult = toolshared.ToolResult
+)
+
+const (
+ handledToolLLMNote = toolshared.HandledToolLLMNote
+ artifactPathsLLMNote = toolshared.ArtifactPathsLLMNote
+
+ ToolPromptLayerCapability = toolshared.ToolPromptLayerCapability
+ ToolPromptSlotTooling = toolshared.ToolPromptSlotTooling
+ ToolPromptSlotMCP = toolshared.ToolPromptSlotMCP
+ ToolPromptSourceRegistry = toolshared.ToolPromptSourceRegistry
+ ToolPromptSourceDiscovery = toolshared.ToolPromptSourceDiscovery
+)
+
+func WithToolContext(ctx context.Context, channel, chatID string) context.Context {
+ return toolshared.WithToolContext(ctx, channel, chatID)
+}
+
+func WithToolMessageContext(ctx context.Context, messageID, replyToMessageID string) context.Context {
+ return toolshared.WithToolMessageContext(ctx, messageID, replyToMessageID)
+}
+
+func WithToolInboundContext(
+ ctx context.Context,
+ channel, chatID, messageID, replyToMessageID string,
+) context.Context {
+ return toolshared.WithToolInboundContext(ctx, channel, chatID, messageID, replyToMessageID)
+}
+
+func WithToolSessionContext(
+ ctx context.Context,
+ agentID, sessionKey string,
+ scope *session.SessionScope,
+) context.Context {
+ return toolshared.WithToolSessionContext(ctx, agentID, sessionKey, scope)
+}
+
+func ToolChannel(ctx context.Context) string {
+ return toolshared.ToolChannel(ctx)
+}
+
+func ToolChatID(ctx context.Context) string {
+ return toolshared.ToolChatID(ctx)
+}
+
+func ToolMessageID(ctx context.Context) string {
+ return toolshared.ToolMessageID(ctx)
+}
+
+func ToolReplyToMessageID(ctx context.Context) string {
+ return toolshared.ToolReplyToMessageID(ctx)
+}
+
+func ToolAgentID(ctx context.Context) string {
+ return toolshared.ToolAgentID(ctx)
+}
+
+func ToolSessionKey(ctx context.Context) string {
+ return toolshared.ToolSessionKey(ctx)
+}
+
+func ToolSessionScope(ctx context.Context) *session.SessionScope {
+ return toolshared.ToolSessionScope(ctx)
+}
+
+func ToolToSchema(tool Tool) map[string]any {
+ return toolshared.ToolToSchema(tool)
+}
+
+func NewToolResult(forLLM string) *ToolResult {
+ return toolshared.NewToolResult(forLLM)
+}
+
+func SilentResult(forLLM string) *ToolResult {
+ return toolshared.SilentResult(forLLM)
+}
+
+func AsyncResult(forLLM string) *ToolResult {
+ return toolshared.AsyncResult(forLLM)
+}
+
+func ErrorResult(message string) *ToolResult {
+ return toolshared.ErrorResult(message)
+}
+
+func UserResult(content string) *ToolResult {
+ return toolshared.UserResult(content)
+}
+
+func MediaResult(forLLM string, mediaRefs []string) *ToolResult {
+ return toolshared.MediaResult(forLLM, mediaRefs)
+}
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index 6ee1cb993..a570ac9ec 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -20,6 +20,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
+ "github.com/sipeed/picoclaw/pkg/isolation"
)
var (
@@ -52,7 +53,7 @@ var (
regexp.MustCompile(`\brmdir\s+/s\b`),
// Match disk wiping commands (must be followed by space/args)
regexp.MustCompile(
- `\b(format|mkfs|diskpart)\b\s`,
+ `(^|[^-\w])\b(format|mkfs|diskpart)\b\s`,
),
regexp.MustCompile(`\bdd\s+if=`),
// Block writes to block devices (all common naming schemes).
@@ -120,7 +121,7 @@ func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regex
func NewExecToolWithConfig(
workingDir string,
restrict bool,
- config *config.Config,
+ cfg *config.Config,
allowPaths ...[]*regexp.Regexp,
) (*ExecTool, error) {
denyPatterns := make([]*regexp.Regexp, 0)
@@ -131,8 +132,8 @@ func NewExecToolWithConfig(
allowedPathPatterns = allowPaths[0]
}
- if config != nil {
- execConfig := config.Tools.Exec
+ if cfg != nil {
+ execConfig := cfg.Tools.Exec
enableDenyPatterns := execConfig.EnableDenyPatterns
allowRemote = execConfig.AllowRemote
if enableDenyPatterns {
@@ -163,8 +164,8 @@ func NewExecToolWithConfig(
}
var timeout time.Duration
- if config != nil && config.Tools.Exec.TimeoutSeconds > 0 {
- timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second
+ if cfg != nil && cfg.Tools.Exec.TimeoutSeconds > 0 {
+ timeout = time.Duration(cfg.Tools.Exec.TimeoutSeconds) * time.Second
}
return &ExecTool{
@@ -378,7 +379,9 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult
cmd.Stdout = &stdout
cmd.Stderr = &stderr
- if err := cmd.Start(); err != nil {
+ // Route shell execution through the shared isolation entry point so exec tool
+ // subprocesses receive the same isolation policy as other integrations.
+ if err := isolation.Start(cmd); err != nil {
return ErrorResult(fmt.Sprintf("failed to start command: %v", err))
}
@@ -521,7 +524,9 @@ func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEn
session.stdinWriter = stdinWriter
}
- if err := cmd.Start(); err != nil {
+ // Background sessions use the same startup path so isolation stays consistent
+ // with synchronous exec runs.
+ if err := isolation.Start(cmd); err != nil {
if session.ptyMaster != nil {
session.ptyMaster.Close()
}
diff --git a/pkg/tools/skills_install_test.go b/pkg/tools/skills_install_test.go
deleted file mode 100644
index 676fcecc0..000000000
--- a/pkg/tools/skills_install_test.go
+++ /dev/null
@@ -1,104 +0,0 @@
-package tools
-
-import (
- "context"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-
- "github.com/sipeed/picoclaw/pkg/skills"
-)
-
-func TestInstallSkillToolName(t *testing.T) {
- tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
- assert.Equal(t, "install_skill", tool.Name())
-}
-
-func TestInstallSkillToolMissingSlug(t *testing.T) {
- tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
- result := tool.Execute(context.Background(), map[string]any{})
- assert.True(t, result.IsError)
- assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string")
-}
-
-func TestInstallSkillToolEmptySlug(t *testing.T) {
- tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
- result := tool.Execute(context.Background(), map[string]any{
- "slug": " ",
- })
- assert.True(t, result.IsError)
- assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string")
-}
-
-func TestInstallSkillToolUnsafeSlug(t *testing.T) {
- tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
-
- cases := []string{
- "../etc/passwd",
- "path/traversal",
- "path\\traversal",
- }
-
- for _, slug := range cases {
- result := tool.Execute(context.Background(), map[string]any{
- "slug": slug,
- })
- assert.True(t, result.IsError, "slug %q should be rejected", slug)
- assert.Contains(t, result.ForLLM, "invalid slug")
- }
-}
-
-func TestInstallSkillToolAlreadyExists(t *testing.T) {
- workspace := t.TempDir()
- skillDir := filepath.Join(workspace, "skills", "existing-skill")
- require.NoError(t, os.MkdirAll(skillDir, 0o755))
-
- tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
- result := tool.Execute(context.Background(), map[string]any{
- "slug": "existing-skill",
- "registry": "clawhub",
- })
- assert.True(t, result.IsError)
- assert.Contains(t, result.ForLLM, "already installed")
-}
-
-func TestInstallSkillToolRegistryNotFound(t *testing.T) {
- workspace := t.TempDir()
- tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
- result := tool.Execute(context.Background(), map[string]any{
- "slug": "some-skill",
- "registry": "nonexistent",
- })
- assert.True(t, result.IsError)
- assert.Contains(t, result.ForLLM, "registry")
- assert.Contains(t, result.ForLLM, "not found")
-}
-
-func TestInstallSkillToolParameters(t *testing.T) {
- tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
- params := tool.Parameters()
-
- props, ok := params["properties"].(map[string]any)
- assert.True(t, ok)
- assert.Contains(t, props, "slug")
- assert.Contains(t, props, "version")
- assert.Contains(t, props, "registry")
- assert.Contains(t, props, "force")
-
- required, ok := params["required"].([]string)
- assert.True(t, ok)
- assert.Contains(t, required, "slug")
- assert.Contains(t, required, "registry")
-}
-
-func TestInstallSkillToolMissingRegistry(t *testing.T) {
- tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
- result := tool.Execute(context.Background(), map[string]any{
- "slug": "some-skill",
- })
- assert.True(t, result.IsError)
- assert.Contains(t, result.ForLLM, "invalid registry")
-}
diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go
index d019d511a..a9a373856 100644
--- a/pkg/tools/spawn.go
+++ b/pkg/tools/spawn.go
@@ -92,11 +92,12 @@ func (t *SpawnTool) execute(
label, _ := args["label"].(string)
agentID, _ := args["agent_id"].(string)
+ targetAgentID := strings.TrimSpace(agentID)
// Check allowlist if targeting a specific agent
- if agentID != "" && t.allowlistCheck != nil {
- if !t.allowlistCheck(agentID) {
- return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", agentID))
+ if targetAgentID != "" && t.allowlistCheck != nil {
+ if !t.allowlistCheck(targetAgentID) {
+ return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", targetAgentID))
}
}
@@ -123,12 +124,14 @@ Task: %s`,
// Launch async sub-turn in goroutine
go func() {
result, err := t.spawner.SpawnSubTurn(ctx, SubTurnConfig{
- Model: t.defaultModel,
- Tools: nil, // Will inherit from parent via context
- SystemPrompt: systemPrompt,
- MaxTokens: t.maxTokens,
- Temperature: t.temperature,
- Async: true, // Async execution
+ Model: t.defaultModel,
+ Tools: nil, // Will inherit from parent via context
+ SystemPrompt: systemPrompt,
+ MaxTokens: t.maxTokens,
+ Temperature: t.temperature,
+ Async: true, // Async execution
+ Critical: true, // Background spawn should survive parent turn completion
+ TargetAgentID: targetAgentID,
})
if err != nil {
result = ErrorResult(fmt.Sprintf("Spawn failed: %v", err)).WithError(err)
diff --git a/pkg/tools/spawn_test.go b/pkg/tools/spawn_test.go
index fda6bbd89..c91c79578 100644
--- a/pkg/tools/spawn_test.go
+++ b/pkg/tools/spawn_test.go
@@ -6,10 +6,18 @@ import (
"testing"
)
-// mockSpawner implements SubTurnSpawner for testing
-type mockSpawner struct{}
+// mockSpawner implements SubTurnSpawner for testing.
+type mockSpawner struct {
+ lastConfig SubTurnConfig
+ done chan struct{}
+}
func (m *mockSpawner) SpawnSubTurn(ctx context.Context, cfg SubTurnConfig) (*ToolResult, error) {
+ m.lastConfig = cfg
+ if m.done != nil {
+ close(m.done)
+ }
+
// Extract task from system prompt for response
task := cfg.SystemPrompt
if strings.Contains(task, "Task: ") {
@@ -62,12 +70,14 @@ func TestSpawnTool_Execute_ValidTask(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSpawnTool(manager)
- tool.SetSpawner(&mockSpawner{})
+ spawner := &mockSpawner{done: make(chan struct{})}
+ tool.SetSpawner(spawner)
ctx := context.Background()
args := map[string]any{
- "task": "Write a haiku about coding",
- "label": "haiku-task",
+ "task": "Write a haiku about coding",
+ "label": "haiku-task",
+ "agent_id": "research",
}
result := tool.Execute(ctx, args)
@@ -80,6 +90,13 @@ func TestSpawnTool_Execute_ValidTask(t *testing.T) {
if !result.Async {
t.Error("SpawnTool should return async result")
}
+ <-spawner.done
+ if spawner.lastConfig.TargetAgentID != "research" {
+ t.Errorf("TargetAgentID = %q, want research", spawner.lastConfig.TargetAgentID)
+ }
+ if !spawner.lastConfig.Critical {
+ t.Error("SpawnTool should mark background subturns as critical")
+ }
}
func TestSpawnTool_Execute_NilManager(t *testing.T) {
diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go
index 9a1a8b802..feeabe536 100644
--- a/pkg/tools/subagent.go
+++ b/pkg/tools/subagent.go
@@ -30,6 +30,7 @@ type SubTurnConfig struct {
ActualSystemPrompt string
InitialMessages []providers.Message
InitialTokenBudget *atomic.Int64 // Shared token budget for team members; nil if no budget
+ TargetAgentID string // If set, run as this agent (its workspace, model, tools)
}
type SubagentTask struct {
@@ -67,6 +68,12 @@ type SubagentManager struct {
hasTemperature bool
nextID int
spawner SpawnSubTurnFunc
+
+ // mediaResolver resolves media:// refs in tool-loop messages before
+ // each LLM call in the legacy RunToolLoop fallback path.
+ // This lets subagents reuse the same media handling behavior as the
+ // main agent loop without importing pkg/agent and creating a cycle.
+ mediaResolver func([]providers.Message) []providers.Message
}
func NewSubagentManager(
@@ -90,6 +97,17 @@ func (sm *SubagentManager) SetSpawner(spawner SpawnSubTurnFunc) {
sm.spawner = spawner
}
+// SetMediaResolver injects a message preprocessor that resolves media:// refs
+// into LLM-ready content before each tool-loop iteration.
+// This is only used by the legacy RunToolLoop fallback path.
+func (sm *SubagentManager) SetMediaResolver(
+ resolver func([]providers.Message) []providers.Message,
+) {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+ sm.mediaResolver = resolver
+}
+
// SetLLMOptions sets max tokens and temperature for subagent LLM calls.
func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
sm.mu.Lock()
@@ -177,6 +195,7 @@ func (sm *SubagentManager) runTask(
temperature := sm.temperature
hasMaxTokens := sm.hasMaxTokens
hasTemperature := sm.hasTemperature
+ mediaResolver := sm.mediaResolver
sm.mu.RUnlock()
var result *ToolResult
@@ -223,6 +242,7 @@ After completing the task, provide a clear summary of what was done.`
Tools: tools,
MaxIterations: maxIter,
LLMOptions: llmOptions,
+ MediaResolver: mediaResolver,
}, messages, task.OriginChannel, task.OriginChatID)
if err == nil {
diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go
index 387813e94..ac568f598 100644
--- a/pkg/tools/toolloop.go
+++ b/pkg/tools/toolloop.go
@@ -24,6 +24,11 @@ type ToolLoopConfig struct {
Tools *ToolRegistry
MaxIterations int
LLMOptions map[string]any
+
+ // MediaResolver resolves media:// refs in messages before each LLM call.
+ // This is optional and is mainly used by subagent legacy fallback execution
+ // so subagents can reuse the same multimodal media handling as the main loop.
+ MediaResolver func(messages []providers.Message) []providers.Message
}
// ToolLoopResult contains the result of running the tool loop.
@@ -63,8 +68,27 @@ func RunToolLoop(
if llmOpts == nil {
llmOpts = map[string]any{}
}
- // 3. Call LLM
- response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts)
+
+ // 3. Resolve media:// refs and Call LLM.
+ // Tools like load_image produce media:// refs in their result messages.
+ // Without this step, the LLM would receive raw "media://uuid" strings
+ // instead of base64-encoded image data URLs.
+ //
+ // We build a separate callMessages slice so that:
+ // (a) the resolver output is used for the LLM call only,
+ // (b) the original `messages` slice keeps the unresolved refs for
+ // subsequent iterations — the resolver is idempotent but working
+ // on the original avoids double-encoding issues.
+ //
+ // On iteration 1 the initial user messages typically have no media://
+ // refs (they come from plain text), so this is effectively a no-op;
+ // it becomes relevant from iteration 2 onward when tool results may
+ // contain media refs.
+ callMessages := messages
+ if config.MediaResolver != nil && iteration > 1 {
+ callMessages = config.MediaResolver(messages)
+ }
+ response, err := config.Provider.Chat(ctx, callMessages, providerToolDefs, config.Model, llmOpts)
if err != nil {
logger.ErrorCF("toolloop", "LLM call failed",
map[string]any{
@@ -161,11 +185,15 @@ func RunToolLoop(
for _, r := range results {
contentForLLM := r.result.ContentForLLM()
- messages = append(messages, providers.Message{
+ toolMsg := providers.Message{
Role: "tool",
Content: contentForLLM,
ToolCallID: r.tc.ID,
- })
+ }
+ if len(r.result.Media) > 0 && !r.result.ResponseHandled {
+ toolMsg.Media = append(toolMsg.Media, r.result.Media...)
+ }
+ messages = append(messages, toolMsg)
}
}
diff --git a/pkg/updater/updater.go b/pkg/updater/updater.go
new file mode 100644
index 000000000..2d4cc950e
--- /dev/null
+++ b/pkg/updater/updater.go
@@ -0,0 +1,717 @@
+package updater
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "compress/gzip"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/minio/selfupdate"
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/utils"
+)
+
+// httpClient is a shared HTTP client used for release checks and downloads.
+// The Timeout value applies to the entire HTTP request: dialing, TLS
+// handshake, redirects, and reading the response body. It is NOT only
+// a connection (dial) timeout. To control lower-level timeouts (dial,
+// TLS handshake, response header wait), supply a custom Transport with
+// an appropriately configured net.Dialer.
+var httpClient = &http.Client{Timeout: 2 * time.Minute}
+
+func getWithRetry(rawURL string) (*http.Response, error) {
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ return utils.DoRequestWithRetry(httpClient, req)
+}
+
+// DownloadAndExtractRelease downloads a release archive (or uses a direct
+// asset URL) and extracts it to a temporary directory. It returns the
+// extraction directory on success. If releaseURL is empty, the latest
+// release of the current project is used. platform/arch can be used to
+// select the correct asset (e.g. "linux", "amd64").
+func DownloadAndExtractRelease(releaseURL, platform, arch string) (string, error) {
+ assetURL, checksum, err := findAssetInfo(releaseURL, platform, arch)
+ if err != nil {
+ return "", err
+ }
+
+ // Download asset to temp file. Use the asset URL extension so
+ // extractArchive can detect the archive format (zip/tar.gz/tar).
+ tmpPattern := "picoclaw-release-*"
+ if u, perr := url.Parse(assetURL); perr == nil {
+ base := filepath.Base(u.Path)
+ lbase := strings.ToLower(base)
+ switch {
+ case strings.HasSuffix(lbase, ".zip"):
+ tmpPattern += ".zip"
+ case strings.HasSuffix(lbase, ".tar.gz") || strings.HasSuffix(lbase, ".tgz"):
+ tmpPattern += ".tar.gz"
+ case strings.HasSuffix(lbase, ".tar"):
+ tmpPattern += ".tar"
+ default:
+ tmpPattern += ".archive"
+ }
+ } else {
+ tmpPattern += ".archive"
+ }
+
+ tmpFile, err := os.CreateTemp("", tmpPattern)
+ if err != nil {
+ return "", err
+ }
+ tmpPath := tmpFile.Name()
+ defer tmpFile.Close()
+
+ resp, err := getWithRetry(assetURL)
+ if err != nil {
+ os.Remove(tmpPath)
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ os.Remove(tmpPath)
+ return "", fmt.Errorf("failed to download asset: status %d", resp.StatusCode)
+ }
+
+ // Stream download while computing SHA256 to avoid a second download.
+ // Also show a simple progress line to stderr so users see activity.
+ h := sha256.New()
+ pw := &progressWriter{total: resp.ContentLength}
+ mw := io.MultiWriter(tmpFile, h, pw)
+ if _, err = io.Copy(mw, resp.Body); err != nil {
+ _ = os.Remove(tmpPath)
+ return "", err
+ }
+ // ensure final progress line ends with newline
+ pw.Finish()
+
+ // verify checksum if available
+ if checksum != "" {
+ got := hex.EncodeToString(h.Sum(nil))
+ if !strings.EqualFold(got, checksum) {
+ _ = os.Remove(tmpPath)
+ return "", fmt.Errorf("checksum mismatch: got %s expected %s", got, checksum)
+ }
+ }
+
+ // Extract
+ destDir, err := os.MkdirTemp("", "picoclaw-extract-*")
+ if err != nil {
+ os.Remove(tmpPath)
+ return "", err
+ }
+
+ if err := extractArchive(tmpPath, destDir); err != nil {
+ os.Remove(tmpPath)
+ os.RemoveAll(destDir)
+ return "", err
+ }
+
+ // cleanup archive file; keep extracted contents
+ _ = os.Remove(tmpPath)
+ return destDir, nil
+}
+
+// UpdateSelfFromRelease downloads the release matching the given parameters,
+// extracts it and applies the binary named programName to update the
+// currently running executable using minio/selfupdate.
+// If releaseURL is empty, the latest release is used. If platform or arch
+// is empty, runtime values are used.
+func UpdateSelfFromRelease(releaseURL, platform, arch, programName string) error {
+ if platform == "" {
+ platform = runtime.GOOS
+ }
+ if arch == "" {
+ arch = runtime.GOARCH
+ }
+
+ dir, err := DownloadAndExtractRelease(releaseURL, platform, arch)
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(dir)
+
+ binPath, err := findBinaryInDir(dir, programName)
+ if err != nil {
+ return err
+ }
+
+ // ensure executable bit on non-windows
+ if runtime.GOOS != "windows" {
+ _ = os.Chmod(binPath, 0o755)
+ }
+
+ f, err := os.Open(binPath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ // Backup current executable so we can roll back if needed.
+ var opts selfupdate.Options
+ if exePath, err := os.Executable(); err == nil {
+ opts.OldSavePath = exePath + ".old"
+ }
+
+ if err := selfupdate.Apply(f, opts); err != nil {
+ return fmt.Errorf("apply update: %w", err)
+ }
+
+ return nil
+}
+
+// UpdateSelf updates the running executable by fetching the latest release
+// and applying the binary matching programName.
+func UpdateSelf(programName string) error {
+ // By default, select the latest stable release when no explicit
+ // release URL is provided. Use --nightly or a custom URL to override.
+ return UpdateSelfFromRelease("", runtime.GOOS, runtime.GOARCH, programName)
+}
+
+// GetReleaseAPIURL returns the GitHub Releases API URL for the given repo owner.
+// Example: owner="sky5454" -> https://api.github.com/repos/sky5454/picoclaw/releases/latest
+func GetReleaseAPIURL(owner string) string {
+ return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/latest", owner)
+}
+
+// GetProdReleaseAPIURL returns the production release API URL (upstream).
+func GetProdReleaseAPIURL() string {
+ return GetReleaseAPIURL("sipeed")
+}
+
+// GetReleaseTagAPIURL returns the GitHub Releases API URL for a specific tag.
+// Example: owner="sipeed", tag="nightly" -> https://api.github.com/repos/sipeed/picoclaw/releases/tags/nightly
+func GetReleaseTagAPIURL(owner, tag string) string {
+ return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/tags/%s", owner, tag)
+}
+
+// GetNightlyReleaseAPIURL returns the nightly release API URL for the production repo.
+func GetNightlyReleaseAPIURL() string {
+ return GetReleaseTagAPIURL("sipeed", "nightly")
+}
+
+// findAssetURL resolves the appropriate asset URL for the given release
+// selector. It accepts direct archive URLs as well as GitHub release URLs
+// or empty (latest release for the project).
+func findAssetInfo(releaseURL, platform, arch string) (string, string, error) {
+ // returns (assetURL, sha256ChecksumHex, error)
+ if looksLikeDirectAssetURL(releaseURL) {
+ return "", "", fmt.Errorf("no checksum found for asset %s", releaseURL)
+ }
+
+ apiURL := buildReleaseAPIURL(releaseURL)
+ if apiURL == "" {
+ // If caller provided an empty releaseURL, default to the
+ // production latest release API URL (stable release).
+ apiURL = GetProdReleaseAPIURL()
+ }
+
+ resp, err := getWithRetry(apiURL)
+ if err != nil {
+ return "", "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return "", "", fmt.Errorf("failed to query releases: status %d", resp.StatusCode)
+ }
+
+ var data struct {
+ TagName string `json:"tag_name"`
+ Assets []struct {
+ Name string `json:"name"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+ Digest string `json:"digest"`
+ } `json:"assets"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ return "", "", err
+ }
+
+ // Selection order: platform -> arch -> extension.
+ platformLower := strings.ToLower(platform)
+ archLower := strings.ToLower(arch)
+
+ isZip := func(name string) bool {
+ return strings.HasSuffix(name, ".zip")
+ }
+ isTarGz := func(name string) bool {
+ return strings.HasSuffix(name, ".tar.gz") || strings.HasSuffix(name, ".tgz")
+ }
+ isTar := func(name string) bool { return strings.HasSuffix(name, ".tar") }
+
+ // collect indices of assets that contain platform (if provided)
+ var platformIdx []int
+ for i, a := range data.Assets {
+ n := strings.ToLower(a.Name)
+ if platform == "" || strings.Contains(n, platformLower) {
+ platformIdx = append(platformIdx, i)
+ }
+ }
+
+ pickBest := func(idxs []int) (string, int, bool) {
+ if len(idxs) == 0 {
+ return "", -1, false
+ }
+ // prefer arch matches within idxs; if arch was specified but
+ // no arch match exists among idxs, treat as no candidate.
+ var archIdx []int
+ if arch != "" {
+ aliases := archAliases(archLower)
+ for _, i := range idxs {
+ n := strings.ToLower(data.Assets[i].Name)
+ for _, ali := range aliases {
+ if strings.Contains(n, ali) {
+ archIdx = append(archIdx, i)
+ break
+ }
+ }
+ }
+ if len(archIdx) == 0 {
+ return "", -1, false
+ }
+ }
+ candidates := archIdx
+ if len(candidates) == 0 {
+ candidates = idxs
+ }
+
+ // extension preference
+ if platformLower == "windows" {
+ // prefer .zip only
+ for _, i := range candidates {
+ if isZip(strings.ToLower(data.Assets[i].Name)) {
+ return data.Assets[i].BrowserDownloadURL, i, true
+ }
+ }
+ // if no zip found, fallthrough to first candidate
+ return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true
+ }
+
+ // non-windows: prefer tar.gz/tgz, then tar, then zip
+ for _, i := range candidates {
+ if isTarGz(strings.ToLower(data.Assets[i].Name)) {
+ return data.Assets[i].BrowserDownloadURL, i, true
+ }
+ }
+ for _, i := range candidates {
+ if isTar(strings.ToLower(data.Assets[i].Name)) {
+ return data.Assets[i].BrowserDownloadURL, i, true
+ }
+ }
+ for _, i := range candidates {
+ if isZip(strings.ToLower(data.Assets[i].Name)) {
+ return data.Assets[i].BrowserDownloadURL, i, true
+ }
+ }
+ // fallback to first candidate
+ return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true
+ }
+
+ // Try platform matches first
+ if url, idx, ok := pickBest(platformIdx); ok {
+ // attempt to find checksum: prefer asset digest from API if present
+ if d := strings.TrimSpace(data.Assets[idx].Digest); d != "" {
+ dLower := strings.ToLower(d)
+ if strings.HasPrefix(dLower, "sha256:") {
+ hexpart := strings.TrimPrefix(dLower, "sha256:")
+ return url, hexpart, nil
+ }
+ // If digest already looks like a 64-hex, return it
+ if ok, _ := regexp.MatchString("(?i)^[a-f0-9]{64}$", dLower); ok {
+ return url, dLower, nil
+ }
+ }
+ // Look for checksum assets and verify by computing the asset's sha256.
+ for j, a := range data.Assets {
+ n := strings.ToLower(a.Name)
+ if strings.Contains(n, "sha256") ||
+ strings.Contains(n, "sha256sum") ||
+ strings.Contains(n, "checksums") ||
+ strings.HasSuffix(n, ".sha256") ||
+ strings.HasSuffix(n, ".sha256sum") {
+ resp2, err := getWithRetry(data.Assets[j].BrowserDownloadURL)
+ if err != nil {
+ continue
+ }
+ bs, err := io.ReadAll(resp2.Body)
+ resp2.Body.Close()
+ if err != nil {
+ continue
+ }
+ if h, ok := findHashInChecksumContent(bs, url); ok {
+ return url, h, nil
+ }
+ }
+ }
+ // No checksum found for the selected platform asset -> error
+ return "", "", fmt.Errorf("no checksum found for asset %s", url)
+ }
+
+ // No platform match — require explicit platform+arch; fail fast.
+ return "", "", fmt.Errorf("no release asset matching platform %q and arch %q", platform, arch)
+}
+
+func looksLikeDirectAssetURL(u string) bool {
+ if u == "" {
+ return false
+ }
+ lower := strings.ToLower(u)
+ if strings.HasSuffix(lower, ".zip") ||
+ strings.HasSuffix(lower, ".tar.gz") ||
+ strings.HasSuffix(lower, ".tgz") ||
+ strings.HasSuffix(lower, ".tar") {
+ return true
+ }
+ if strings.Contains(lower, "/releases/download/") {
+ return true
+ }
+ return false
+}
+
+func buildReleaseAPIURL(releaseURL string) string {
+ if releaseURL == "" {
+ return ""
+ }
+ if strings.Contains(releaseURL, "api.github.com") {
+ return releaseURL
+ }
+ u, err := url.Parse(releaseURL)
+ if err != nil {
+ return ""
+ }
+ if u.Host != "github.com" {
+ return ""
+ }
+ parts := strings.Split(strings.Trim(u.Path, "/"), "/")
+ if len(parts) < 2 {
+ return ""
+ }
+ owner := parts[0]
+ repo := parts[1]
+ // if tag specified
+ if len(parts) >= 5 && parts[2] == "releases" && parts[3] == "tag" {
+ tag := parts[4]
+ return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, tag)
+ }
+ // default to latest
+ return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
+}
+
+// NOTE: helper functions to compute SHA256 from URL/path were removed
+// after refactoring to stream the download and verify the checksum
+// during the single download to avoid double-transfer.
+
+// findHashInChecksumContent attempts to locate a 64-hex SHA256 in the
+// checksum file content that corresponds to assetURL. It returns the
+// found hash (lowercase) and true, or "", false if not found.
+func findHashInChecksumContent(bs []byte, assetURL string) (string, bool) {
+ s := strings.ToLower(string(bs))
+ var assetBase string
+ if u, err := url.Parse(assetURL); err == nil {
+ assetBase = strings.ToLower(filepath.Base(u.Path))
+ } else {
+ assetBase = strings.ToLower(filepath.Base(assetURL))
+ }
+ re := regexp.MustCompile(`(?i)\b([a-f0-9]{64})\b`)
+ // prefer a line containing the asset filename
+ for _, line := range strings.Split(s, "\n") {
+ if strings.Contains(line, assetBase) {
+ if m := re.FindString(line); m != "" {
+ return m, true
+ }
+ }
+ }
+ // fallback: if there's exactly one unique 64-hex value, return it
+ matches := re.FindAllString(s, -1)
+ uniq := map[string]struct{}{}
+ for _, m := range matches {
+ uniq[m] = struct{}{}
+ }
+ if len(uniq) == 1 {
+ for k := range uniq {
+ return k, true
+ }
+ }
+ return "", false
+}
+
+// progressWriter implements io.Writer and prints a simple progress
+// line to stderr while bytes are written. It is intended to be used
+// as one writer in an io.MultiWriter so we can stream-to-disk, compute
+// the sha256, and update the progress display in a single pass.
+type progressWriter struct {
+ total int64
+ written int64
+ last time.Time
+}
+
+func (pw *progressWriter) Write(p []byte) (int, error) {
+ n := len(p)
+ pw.written += int64(n)
+ now := time.Now()
+ if pw.last.IsZero() || now.Sub(pw.last) >= 200*time.Millisecond || (pw.total > 0 && pw.written == pw.total) {
+ pw.print()
+ pw.last = now
+ }
+ return n, nil
+}
+
+func (pw *progressWriter) print() {
+ if pw.total > 0 {
+ pct := float64(pw.written) * 100.0 / float64(pw.total)
+ fmt.Fprintf(os.Stderr, "\rDownloading: %s / %s (%.1f%%)", humanBytes(pw.written), humanBytes(pw.total), pct)
+ } else {
+ fmt.Fprintf(os.Stderr, "\rDownloading: %s", humanBytes(pw.written))
+ }
+}
+
+func (pw *progressWriter) Finish() {
+ pw.print()
+ fmt.Fprintln(os.Stderr, "")
+}
+
+func humanBytes(n int64) string {
+ f := float64(n)
+ const (
+ KB = 1024.0
+ MB = KB * 1024.0
+ GB = MB * 1024.0
+ )
+ switch {
+ case f >= GB:
+ return fmt.Sprintf("%.2f GB", f/GB)
+ case f >= MB:
+ return fmt.Sprintf("%.2f MB", f/MB)
+ case f >= KB:
+ return fmt.Sprintf("%.2f KB", f/KB)
+ default:
+ return fmt.Sprintf("%d B", n)
+ }
+}
+
+// archAliases returns common name variants for an architecture string
+// so we can match release asset names like "x86_64" vs Go's "amd64".
+// archAliases returns name variants for an architecture string.
+// If `arch` is empty or matches the local runtime.GOARCH, prefer the
+// compile-time architecture aliases provided by archAliasesForLocal
+// (implemented per-architecture via build tags). For other `arch`
+// values we use a small synonyms map.
+func archAliases(arch string) []string {
+ a := strings.ToLower(arch)
+ if syns, ok := archSynonyms[a]; ok {
+ return syns
+ }
+ return []string{a}
+}
+
+var archSynonyms = map[string][]string{
+ "amd64": {"amd64", "x86_64", "x64"},
+ "x86_64": {"amd64", "x86_64", "x64"},
+ "x64": {"amd64", "x86_64", "x64"},
+ "386": {"386", "x86"},
+ "x86": {"386", "x86"},
+ "arm64": {"arm64", "aarch64"},
+ "aarch64": {"arm64", "aarch64"},
+ "arm": {"arm"},
+}
+
+func extractArchive(archivePath, destDir string) error {
+ lower := strings.ToLower(archivePath)
+ if strings.HasSuffix(lower, ".zip") {
+ return extractZip(archivePath, destDir)
+ }
+ // treat .tar.gz and .tgz as gzip+tar
+ if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") {
+ return extractTarGz(archivePath, destDir)
+ }
+ if strings.HasSuffix(lower, ".tar") {
+ return extractTar(archivePath, destDir)
+ }
+ // fallback: try tar.gz
+ return extractTarGz(archivePath, destDir)
+}
+
+func extractZip(archivePath, destDir string) error {
+ r, err := zip.OpenReader(archivePath)
+ if err != nil {
+ return err
+ }
+ defer r.Close()
+ destClean := filepath.Clean(destDir)
+ for _, f := range r.File {
+ target := filepath.Clean(filepath.Join(destClean, f.Name))
+ if !strings.HasPrefix(target, destClean+string(os.PathSeparator)) && target != destClean {
+ return fmt.Errorf("path traversal detected: %s", f.Name)
+ }
+ if f.FileInfo().IsDir() {
+ if err := os.MkdirAll(target, f.FileInfo().Mode()); err != nil {
+ return err
+ }
+ continue
+ }
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ rc, err := f.Open()
+ if err != nil {
+ return err
+ }
+ out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, f.FileInfo().Mode())
+ if err != nil {
+ rc.Close()
+ return err
+ }
+ if _, err := io.Copy(out, rc); err != nil {
+ rc.Close()
+ out.Close()
+ return err
+ }
+ rc.Close()
+ out.Close()
+ }
+ return nil
+}
+
+func extractTarGz(archivePath, destDir string) error {
+ f, err := os.Open(archivePath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ gzr, err := gzip.NewReader(f)
+ if err != nil {
+ return err
+ }
+ defer gzr.Close()
+ tr := tar.NewReader(gzr)
+ return extractTarFromReader(tr, destDir)
+}
+
+func extractTar(archivePath, destDir string) error {
+ f, err := os.Open(archivePath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ tr := tar.NewReader(f)
+ return extractTarFromReader(tr, destDir)
+}
+
+// extractTarFromReader contains logic common to extracting entries from a
+// tar.Reader and is used by both extractTarGz and extractTar to avoid
+// duplicated code (golangci-lint: dupl).
+func extractTarFromReader(tr *tar.Reader, destDir string) error {
+ for {
+ hdr, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return err
+ }
+ target := filepath.Clean(filepath.Join(filepath.Clean(destDir), hdr.Name))
+ if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) &&
+ target != filepath.Clean(destDir) {
+ return fmt.Errorf("path traversal detected: %s", hdr.Name)
+ }
+ switch hdr.Typeflag {
+ case tar.TypeDir:
+ if err := os.MkdirAll(target, 0o755); err != nil {
+ return err
+ }
+ case tar.TypeReg:
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode))
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(out, tr); err != nil {
+ out.Close()
+ return err
+ }
+ out.Close()
+ }
+ }
+ return nil
+}
+
+func findBinaryInDir(dir, programName string) (string, error) {
+ wanted := []string{programName}
+ if runtime.GOOS == "windows" {
+ wanted = append([]string{programName + ".exe"}, wanted...)
+ } else {
+ // also accept programs with .exe in archives targeting windows
+ wanted = append(wanted, programName+".exe")
+ }
+
+ var found string
+ if err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
+ if err != nil || found != "" {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ base := filepath.Base(p)
+ for _, w := range wanted {
+ if base == w {
+ found = p
+ return io.EOF // use EOF to stop walking early
+ }
+ }
+ return nil
+ }); err != nil && err != io.EOF {
+ return "", err
+ }
+ if found == "" {
+ return "", fmt.Errorf("binary %q not found in archive", programName)
+ }
+ return found, nil
+}
+
+// NewUpdateCommand returns a cobra command that triggers UpdateSelfFromRelease.
+func NewUpdateCommand(binaryName string) *cobra.Command {
+ var urlStr, platform, arch string
+ cmd := &cobra.Command{
+ Use: "update",
+ Short: "Check and apply updates from GitHub releases",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if platform == "" {
+ platform = runtime.GOOS
+ }
+ if arch == "" {
+ arch = runtime.GOARCH
+ }
+ fmt.Printf("Current version: %s\n", config.FormatVersion())
+ if err := UpdateSelfFromRelease(urlStr, platform, arch, binaryName); err != nil {
+ return err
+ }
+ fmt.Println("Update applied; restart to use the new version.")
+ return nil
+ },
+ }
+ cmd.Flags().StringVarP(&urlStr, "url", "u", "", "Direct URL to download release asset or release page")
+ cmd.Flags().StringVar(&platform, "platform", "", "Target platform (default: runtime.GOOS)")
+ cmd.Flags().StringVar(&arch, "arch", "", "Target arch (default: runtime.GOARCH)")
+ return cmd
+}
diff --git a/pkg/updater/updater_test.go b/pkg/updater/updater_test.go
new file mode 100644
index 000000000..75159af12
--- /dev/null
+++ b/pkg/updater/updater_test.go
@@ -0,0 +1,415 @@
+package updater
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "bytes"
+ "compress/gzip"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// matchesMagic checks whether the file at path looks like a platform binary
+// by inspecting magic bytes (ELF for linux, MZ for windows).
+func matchesMagic(path, platform string) (bool, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return false, err
+ }
+ defer f.Close()
+ buf := make([]byte, 4)
+ n, err := f.Read(buf)
+ if err != nil && err != io.EOF {
+ return false, err
+ }
+ if n >= 4 && buf[0] == 0x7f && buf[1] == 'E' && buf[2] == 'L' && buf[3] == 'F' {
+ return strings.Contains(platform, "linux"), nil
+ }
+ if n >= 2 && buf[0] == 'M' && buf[1] == 'Z' {
+ return strings.Contains(platform, "windows"), nil
+ }
+ return false, nil
+}
+
+type testReleaseAsset struct {
+ Name string `json:"name"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+ Digest string `json:"digest,omitempty"`
+}
+
+type testReleasePayload struct {
+ TagName string `json:"tag_name"`
+ Assets []testReleaseAsset `json:"assets"`
+}
+
+const testReleaseAPIPath = "/api.github.com/repos/sipeed/picoclaw/releases/latest"
+
+// TestDownloadAndExtractRelease_IntegrationLatestRelease downloads the latest
+// public release for a single platform as an opt-in smoke test.
+func TestDownloadAndExtractRelease_IntegrationLatestRelease(t *testing.T) {
+ if os.Getenv("PICOCLAW_INTEGRATION_TESTS") == "" {
+ t.Skip("skipping integration test (set PICOCLAW_INTEGRATION_TESTS=1 to enable)")
+ }
+ if testing.Short() {
+ t.Skip("skipping integration test in short mode")
+ }
+
+ const platform = "linux"
+ const arch = "amd64"
+ apiURL := GetProdReleaseAPIURL()
+ assetURL, checksum, err := findAssetInfo(apiURL, platform, arch)
+ if err != nil {
+ t.Fatalf("findAssetInfo failed for %s/%s: %v", platform, arch, err)
+ }
+ t.Logf("asset URL: %s checksum: %s", assetURL, checksum)
+
+ dir, err := DownloadAndExtractRelease(apiURL, platform, arch)
+ if err != nil {
+ t.Fatalf("DownloadAndExtractRelease failed for %s/%s: %v", platform, arch, err)
+ }
+ defer os.RemoveAll(dir)
+
+ var found bool
+ _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ info, err := d.Info()
+ if err != nil {
+ return err
+ }
+ if info.Size() < 64 {
+ return nil
+ }
+ ok, err := matchesMagic(path, platform)
+ if err != nil {
+ return err
+ }
+ if ok {
+ found = true
+ t.Logf("found artifact: %s (size=%d)", path, info.Size())
+ }
+ return nil
+ })
+ if !found {
+ t.Fatalf("no binary-like artifact found for %s/%s", platform, arch)
+ }
+}
+
+func TestFindAssetInfo_SelectsPreferredAsset(t *testing.T) {
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case testReleaseAPIPath:
+ writeReleasePayload(w, testReleasePayload{
+ TagName: "v0.2.6",
+ Assets: []testReleaseAsset{
+ {
+ Name: "picoclaw_Linux_x86_64.zip",
+ BrowserDownloadURL: server.URL + "/assets/picoclaw_Linux_x86_64.zip",
+ Digest: "sha256:" + strings.Repeat("1", 64),
+ },
+ {
+ Name: "picoclaw_Linux_x86_64.tar.gz",
+ BrowserDownloadURL: server.URL + "/assets/picoclaw_Linux_x86_64.tar.gz",
+ Digest: "sha256:" + strings.Repeat("2", 64),
+ },
+ {
+ Name: "picoclaw_Windows_x86_64.zip",
+ BrowserDownloadURL: server.URL + "/assets/picoclaw_Windows_x86_64.zip",
+ Digest: "sha256:" + strings.Repeat("3", 64),
+ },
+ {
+ Name: "picoclaw_Windows_arm64.zip",
+ BrowserDownloadURL: server.URL + "/assets/picoclaw_Windows_arm64.zip",
+ Digest: "sha256:" + strings.Repeat("4", 64),
+ },
+ },
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ withTestHTTPClient(t, server.Client())
+
+ tests := []struct {
+ name string
+ platform string
+ arch string
+ wantURL string
+ wantChecksum string
+ }{
+ {
+ name: "linux prefers tar.gz over zip",
+ platform: "linux",
+ arch: "amd64",
+ wantURL: server.URL + "/assets/picoclaw_Linux_x86_64.tar.gz",
+ wantChecksum: strings.Repeat("2", 64),
+ },
+ {
+ name: "windows amd64 matches x86_64 zip",
+ platform: "windows",
+ arch: "amd64",
+ wantURL: server.URL + "/assets/picoclaw_Windows_x86_64.zip",
+ wantChecksum: strings.Repeat("3", 64),
+ },
+ {
+ name: "windows arm64 matches arm64 zip",
+ platform: "windows",
+ arch: "arm64",
+ wantURL: server.URL + "/assets/picoclaw_Windows_arm64.zip",
+ wantChecksum: strings.Repeat("4", 64),
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ gotURL, gotChecksum, err := findAssetInfo(server.URL+testReleaseAPIPath, tc.platform, tc.arch)
+ if err != nil {
+ t.Fatalf(
+ "findAssetInfo(%q, %q, %q) error: %v",
+ server.URL+testReleaseAPIPath,
+ tc.platform,
+ tc.arch,
+ err,
+ )
+ }
+ if gotURL != tc.wantURL {
+ t.Fatalf("assetURL = %q, want %q", gotURL, tc.wantURL)
+ }
+ if gotChecksum != tc.wantChecksum {
+ t.Fatalf("checksum = %q, want %q", gotChecksum, tc.wantChecksum)
+ }
+ })
+ }
+}
+
+func TestFindAssetInfo_UsesChecksumAssetWhenDigestMissing(t *testing.T) {
+ const checksum = "77b564f36da6d1e02169d0ecc837728eecb9ef983c317d9186ac9651798b924c"
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case testReleaseAPIPath:
+ writeReleasePayload(w, testReleasePayload{
+ TagName: "v0.2.6",
+ Assets: []testReleaseAsset{
+ {
+ Name: "picoclaw_Windows_x86_64.zip",
+ BrowserDownloadURL: server.URL + "/assets/picoclaw_Windows_x86_64.zip",
+ },
+ {
+ Name: "checksums.txt",
+ BrowserDownloadURL: server.URL + "/assets/checksums.txt",
+ },
+ },
+ })
+ case "/assets/checksums.txt":
+ _, _ = io.WriteString(w, checksum+" picoclaw_Windows_x86_64.zip\n")
+ case "/assets/picoclaw_Windows_x86_64.zip":
+ w.WriteHeader(http.StatusInternalServerError)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ withTestHTTPClient(t, server.Client())
+
+ gotURL, gotChecksum, err := findAssetInfo(server.URL+testReleaseAPIPath, "windows", "amd64")
+ if err != nil {
+ t.Fatalf("findAssetInfo returned error: %v", err)
+ }
+ if gotURL != server.URL+"/assets/picoclaw_Windows_x86_64.zip" {
+ t.Fatalf("assetURL = %q, want %q", gotURL, server.URL+"/assets/picoclaw_Windows_x86_64.zip")
+ }
+ if gotChecksum != checksum {
+ t.Fatalf("checksum = %q, want %q", gotChecksum, checksum)
+ }
+}
+
+func TestDownloadAndExtractRelease_ExtractsTarGz(t *testing.T) {
+ tarGzContent := buildTestTarGz(t, map[string]string{
+ "picoclaw_Linux_x86_64/picoclaw": "test linux binary payload",
+ })
+ sum := sha256.Sum256(tarGzContent)
+ checksum := hex.EncodeToString(sum[:])
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case testReleaseAPIPath:
+ writeReleasePayload(w, testReleasePayload{
+ TagName: "v0.2.6",
+ Assets: []testReleaseAsset{
+ {
+ Name: "picoclaw_Linux_x86_64.tar.gz",
+ BrowserDownloadURL: server.URL + "/assets/picoclaw_Linux_x86_64.tar.gz",
+ Digest: "sha256:" + checksum,
+ },
+ },
+ })
+ case "/assets/picoclaw_Linux_x86_64.tar.gz":
+ w.Header().Set("Content-Type", "application/gzip")
+ _, _ = w.Write(tarGzContent)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ withTestHTTPClient(t, server.Client())
+
+ dir, err := DownloadAndExtractRelease(server.URL+testReleaseAPIPath, "linux", "amd64")
+ if err != nil {
+ t.Fatalf("DownloadAndExtractRelease returned error: %v", err)
+ }
+ defer os.RemoveAll(dir)
+
+ binPath, err := findBinaryInDir(dir, "picoclaw")
+ if err != nil {
+ t.Fatalf("findBinaryInDir returned error: %v", err)
+ }
+
+ bs, err := os.ReadFile(binPath)
+ if err != nil {
+ t.Fatalf("ReadFile extracted asset: %v", err)
+ }
+ if got := string(bs); got != "test linux binary payload" {
+ t.Fatalf("extracted content = %q, want %q", got, "test linux binary payload")
+ }
+}
+
+func TestDownloadAndExtractRelease_RetriesTransientAssetFailure(t *testing.T) {
+ zipContent := buildTestZip(t, map[string]string{
+ "picoclaw.exe": "test windows binary payload",
+ })
+ sum := sha256.Sum256(zipContent)
+ checksum := hex.EncodeToString(sum[:])
+
+ var assetAttempts int
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api.github.com/repos/sipeed/picoclaw/releases/latest":
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(
+ w,
+ `{"tag_name":"v0.2.6","assets":[{"name":"picoclaw_Windows_x86_64.zip","browser_download_url":%q,"digest":"sha256:%s"}]}`,
+ server.URL+"/assets/picoclaw_Windows_x86_64.zip",
+ checksum,
+ )
+ case "/assets/picoclaw_Windows_x86_64.zip":
+ assetAttempts++
+ if assetAttempts == 1 {
+ w.WriteHeader(http.StatusGatewayTimeout)
+ return
+ }
+ w.Header().Set("Content-Type", "application/zip")
+ _, _ = w.Write(zipContent)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ withTestHTTPClient(t, server.Client())
+
+ dir, err := DownloadAndExtractRelease(
+ server.URL+"/api.github.com/repos/sipeed/picoclaw/releases/latest",
+ "windows",
+ "amd64",
+ )
+ if err != nil {
+ t.Fatalf("DownloadAndExtractRelease returned error: %v", err)
+ }
+ defer os.RemoveAll(dir)
+
+ if assetAttempts != 2 {
+ t.Fatalf("asset attempts = %d, want 2", assetAttempts)
+ }
+
+ bs, err := os.ReadFile(filepath.Join(dir, "picoclaw.exe"))
+ if err != nil {
+ t.Fatalf("ReadFile extracted asset: %v", err)
+ }
+ if got := string(bs); got != "test windows binary payload" {
+ t.Fatalf("extracted content = %q, want %q", got, "test windows binary payload")
+ }
+}
+
+func buildTestZip(t *testing.T, files map[string]string) []byte {
+ t.Helper()
+
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ for name, content := range files {
+ w, err := zw.Create(name)
+ if err != nil {
+ t.Fatalf("Create zip entry %q: %v", name, err)
+ }
+ if _, err := io.WriteString(w, content); err != nil {
+ t.Fatalf("Write zip entry %q: %v", name, err)
+ }
+ }
+ if err := zw.Close(); err != nil {
+ t.Fatalf("Close zip writer: %v", err)
+ }
+ return buf.Bytes()
+}
+
+func buildTestTarGz(t *testing.T, files map[string]string) []byte {
+ t.Helper()
+
+ var buf bytes.Buffer
+ gzw := gzip.NewWriter(&buf)
+ tw := tar.NewWriter(gzw)
+
+ for name, content := range files {
+ if err := tw.WriteHeader(&tar.Header{
+ Name: name,
+ Mode: 0o755,
+ Size: int64(len(content)),
+ }); err != nil {
+ t.Fatalf("Write tar header %q: %v", name, err)
+ }
+ if _, err := io.WriteString(tw, content); err != nil {
+ t.Fatalf("Write tar entry %q: %v", name, err)
+ }
+ }
+ if err := tw.Close(); err != nil {
+ t.Fatalf("Close tar writer: %v", err)
+ }
+ if err := gzw.Close(); err != nil {
+ t.Fatalf("Close gzip writer: %v", err)
+ }
+ return buf.Bytes()
+}
+
+func writeReleasePayload(w http.ResponseWriter, payload testReleasePayload) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(payload)
+}
+
+func withTestHTTPClient(t *testing.T, client *http.Client) {
+ t.Helper()
+
+ origClient := httpClient
+ httpClient = client
+ httpClient.Timeout = 5 * time.Second
+ t.Cleanup(func() {
+ httpClient = origClient
+ })
+}
diff --git a/pkg/utils/bm25.go b/pkg/utils/bm25.go
index 95c63f0e3..f8b9f6882 100644
--- a/pkg/utils/bm25.go
+++ b/pkg/utils/bm25.go
@@ -29,18 +29,18 @@ const (
DefaultBM25B = 0.75
)
-// BM25Engine is a query-time BM25 search engine over a generic corpus.
+// BM25Engine is a BM25 search engine over a generic corpus.
// T is the document type; the caller supplies a TextFunc that extracts the
// searchable text from each document.
//
-// The engine is stateless between queries: no caching, no invalidation logic.
-// All indexing work is performed inside Search() on every call, making it
-// safe to use on corpora that change frequently.
+// The engine precomputes its index once at construction time and reuses it for
+// subsequent searches. If the corpus content changes, construct a new engine.
type BM25Engine[T any] struct {
corpus []T
textFunc func(T) string
k1 float64
b float64
+ index *bm25Index
}
// BM25Option is a functional option to configure a BM25Engine.
@@ -51,6 +51,17 @@ type bm25Config struct {
b float64
}
+type bm25Index struct {
+ entries []bm25DocEntry
+ idf map[string]float32
+ docLenNorm []float32
+ posting map[string][]int32
+}
+
+type bm25DocEntry struct {
+ tf map[string]uint32
+}
+
// WithK1 overrides the term-frequency saturation constant (default 1.2).
func WithK1(k1 float64) BM25Option {
return func(c *bm25Config) { c.k1 = k1 }
@@ -74,12 +85,14 @@ func NewBM25Engine[T any](corpus []T, textFunc func(T) string, opts ...BM25Optio
for _, o := range opts {
o(&cfg)
}
- return &BM25Engine[T]{
+ engine := &BM25Engine[T]{
corpus: corpus,
textFunc: textFunc,
k1: cfg.k1,
b: cfg.b,
}
+ engine.index = buildBM25Index(corpus, textFunc, cfg.k1, cfg.b)
+ return engine
}
// BM25Result is a single ranked result from a Search call.
@@ -91,9 +104,8 @@ type BM25Result[T any] struct {
// Search ranks the corpus against query and returns the top-k results.
// Returns an empty slice (not nil) when there are no matches.
//
-// Complexity: O(N×L) for indexing + O(|Q|×avgPostingLen) for scoring,
-// where N = corpus size, L = average document length, Q = query terms.
-// Top-k extraction uses a fixed-size min-heap: O(candidates × log k).
+// Complexity: O(|Q|×avgPostingLen + candidates × log k) per search after the
+// one-time indexing work performed by NewBM25Engine.
func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
if topK <= 0 {
return []BM25Result[T]{}
@@ -104,78 +116,24 @@ func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
return []BM25Result[T]{}
}
- N := len(e.corpus)
- if N == 0 {
+ if len(e.corpus) == 0 || e.index == nil {
return []BM25Result[T]{}
}
- // Step 1: build per-document tf + raw doc lengths
- type docEntry struct {
- tf map[string]uint32
- rawLen int
- }
-
- entries := make([]docEntry, N)
- df := make(map[string]int, 64)
- totalLen := 0
-
- for i, doc := range e.corpus {
- tokens := bm25Tokenize(e.textFunc(doc))
- totalLen += len(tokens)
-
- tf := make(map[string]uint32, len(tokens))
- for _, t := range tokens {
- tf[t]++
- }
- // df: each term counts once per document (iterate the map, keys are unique)
- for t := range tf {
- df[t]++
- }
-
- entries[i] = docEntry{tf: tf, rawLen: len(tokens)}
- }
-
- avgDocLen := float64(totalLen) / float64(N)
-
- // Step 2: pre-compute IDF and per-doc length normalization
- // IDF (Robertson smoothing): log( (N - df(t) + 0.5) / (df(t) + 0.5) + 1 )
- idf := make(map[string]float32, len(df))
- for term, freq := range df {
- idf[term] = float32(math.Log(
- (float64(N)-float64(freq)+0.5)/(float64(freq)+0.5) + 1,
- ))
- }
-
- // docLenNorm[i] = k1 * (1 - b + b * |doc_i| / avgDocLen)
- // Stored as float32 — sufficient precision for ranking.
- docLenNorm := make([]float32, N)
- for i, entry := range entries {
- docLenNorm[i] = float32(e.k1 * (1 - e.b + e.b*float64(entry.rawLen)/avgDocLen))
- }
-
- // Step 3: build inverted index (posting lists)
- // Iterate the tf map directly — map keys are already unique, no seen-set needed.
- posting := make(map[string][]int32, len(df))
- for i, entry := range entries {
- for term := range entry.tf {
- posting[term] = append(posting[term], int32(i))
- }
- }
-
// Step 4: score via posting lists
// Deduplicate query terms to avoid double-weighting the same term.
unique := bm25Dedupe(queryTerms)
scores := make(map[int32]float32)
for _, term := range unique {
- termIDF, ok := idf[term]
+ termIDF, ok := e.index.idf[term]
if !ok {
continue // term not in vocabulary → zero contribution
}
- for _, docID := range posting[term] {
- freq := float32(entries[docID].tf[term])
+ for _, docID := range e.index.posting[term] {
+ freq := float32(e.index.entries[docID].tf[term])
// TF_norm = freq * (k1+1) / (freq + docLenNorm)
- tfNorm := freq * float32(e.k1+1) / (freq + docLenNorm[docID])
+ tfNorm := freq * float32(e.k1+1) / (freq + e.index.docLenNorm[docID])
scores[docID] += termIDF * tfNorm
}
}
@@ -212,6 +170,65 @@ func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
return out
}
+func buildBM25Index[T any](corpus []T, textFunc func(T) string, k1, b float64) *bm25Index {
+ N := len(corpus)
+ if N == 0 {
+ return nil
+ }
+
+ entries := make([]bm25DocEntry, N)
+ rawLens := make([]int, N)
+ df := make(map[string]int, 64)
+ totalLen := 0
+
+ for i, doc := range corpus {
+ tokens := bm25Tokenize(textFunc(doc))
+ totalLen += len(tokens)
+ rawLens[i] = len(tokens)
+
+ tf := make(map[string]uint32, len(tokens))
+ for _, t := range tokens {
+ tf[t]++
+ }
+ for term := range tf {
+ df[term]++
+ }
+
+ entries[i] = bm25DocEntry{tf: tf}
+ }
+
+ avgDocLen := float64(totalLen) / float64(N)
+ if avgDocLen == 0 {
+ avgDocLen = 1
+ }
+
+ idf := make(map[string]float32, len(df))
+ for term, freq := range df {
+ idf[term] = float32(math.Log(
+ (float64(N)-float64(freq)+0.5)/(float64(freq)+0.5) + 1,
+ ))
+ }
+
+ docLenNorm := make([]float32, N)
+ for i, rawLen := range rawLens {
+ docLenNorm[i] = float32(k1 * (1 - b + b*float64(rawLen)/avgDocLen))
+ }
+
+ posting := make(map[string][]int32, len(df))
+ for i, entry := range entries {
+ for term := range entry.tf {
+ posting[term] = append(posting[term], int32(i))
+ }
+ }
+
+ return &bm25Index{
+ entries: entries,
+ idf: idf,
+ docLenNorm: docLenNorm,
+ posting: posting,
+ }
+}
+
// bm25Tokenize splits s into lowercase tokens, stripping edge punctuation.
func bm25Tokenize(s string) []string {
raw := strings.Fields(strings.ToLower(s))
diff --git a/pkg/utils/bm25_test.go b/pkg/utils/bm25_test.go
index 4bc85b246..216fe733d 100644
--- a/pkg/utils/bm25_test.go
+++ b/pkg/utils/bm25_test.go
@@ -1,7 +1,9 @@
package utils
import (
+ "fmt"
"reflect"
+ "strings"
"testing"
)
@@ -173,3 +175,61 @@ func TestBM25Search_SortingStability(t *testing.T) {
}
}
}
+
+func BenchmarkBM25Search_ReusedIndex(b *testing.B) {
+ corpus := benchmarkBM25Corpus(2000)
+ engine := NewBM25Engine(corpus, extractText)
+ query := "hardware gpio i2c sensor controller latency"
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ results := engine.Search(query, 10)
+ if len(results) == 0 {
+ b.Fatal("expected non-empty results")
+ }
+ }
+}
+
+func BenchmarkBM25Search_RebuildEachTime(b *testing.B) {
+ corpus := benchmarkBM25Corpus(2000)
+ query := "hardware gpio i2c sensor controller latency"
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ engine := NewBM25Engine(corpus, extractText)
+ results := engine.Search(query, 10)
+ if len(results) == 0 {
+ b.Fatal("expected non-empty results")
+ }
+ }
+}
+
+func benchmarkBM25Corpus(size int) []testDoc {
+ corpus := make([]testDoc, size)
+ topics := []string{
+ "hardware gpio pwm adc sensor controller latency throughput",
+ "telegram markdown parser message escape formatting bot command",
+ "jsonl memory session history storage append compact recovery",
+ "openai provider routing agent tool search registry hidden tools",
+ "i2c spi uart serial device bus address transfer clock",
+ }
+
+ for i := range corpus {
+ topic := topics[i%len(topics)]
+ corpus[i] = testDoc{
+ ID: i,
+ Text: fmt.Sprintf(
+ "doc %d %s repeated repeated %s variant-%d %s",
+ i,
+ topic,
+ topic,
+ i%17,
+ strings.Repeat("token ", (i%7)+1),
+ ),
+ }
+ }
+
+ return corpus
+}
diff --git a/pkg/utils/http_retry.go b/pkg/utils/http_retry.go
index 135ea0ef5..514f9781b 100644
--- a/pkg/utils/http_retry.go
+++ b/pkg/utils/http_retry.go
@@ -4,12 +4,16 @@ import (
"context"
"fmt"
"net/http"
+ "strconv"
"time"
)
const maxRetries = 3
-var retryDelayUnit = time.Second
+var (
+ retryDelayUnit = time.Second
+ maxRetrySleepDuration = 1 * time.Minute
+)
func shouldRetry(statusCode int) bool {
return statusCode == http.StatusTooManyRequests ||
@@ -36,7 +40,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response,
}
if i < maxRetries-1 {
- if err = sleepWithCtx(req.Context(), retryDelayUnit*time.Duration(i+1)); err != nil {
+ if err = sleepWithCtx(req.Context(), retryDelayForAttempt(resp, i)); err != nil {
if resp != nil {
resp.Body.Close()
}
@@ -47,6 +51,57 @@ func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response,
return resp, err
}
+func retryDelayForAttempt(resp *http.Response, attempt int) time.Duration {
+ fallback := retryDelayUnit * time.Duration(attempt+1)
+ if resp == nil || resp.StatusCode != http.StatusTooManyRequests {
+ return clampRetryDelay(fallback)
+ }
+
+ retryAfter := resp.Header.Get("Retry-After")
+ if retryAfter == "" {
+ return clampRetryDelay(fallback)
+ }
+
+ if delay, ok := numericRetryAfterDelay(retryAfter); ok {
+ return delay
+ }
+
+ if when, err := http.ParseTime(retryAfter); err == nil {
+ delay := time.Until(when)
+ if serverDate, err := http.ParseTime(resp.Header.Get("Date")); err == nil {
+ delay = when.Sub(serverDate)
+ }
+ if delay < 0 {
+ return 0
+ }
+ return clampRetryDelay(delay)
+ }
+
+ return clampRetryDelay(fallback)
+}
+
+func numericRetryAfterDelay(retryAfter string) (time.Duration, bool) {
+ seconds, err := strconv.ParseInt(retryAfter, 10, 64)
+ if err != nil || seconds < 0 {
+ return 0, false
+ }
+ maxSeconds := int64(maxRetrySleepDuration / time.Second)
+ if seconds > maxSeconds {
+ return maxRetrySleepDuration, true
+ }
+ return clampRetryDelay(time.Duration(seconds) * time.Second), true
+}
+
+func clampRetryDelay(delay time.Duration) time.Duration {
+ if delay <= 0 {
+ return 0
+ }
+ if delay > maxRetrySleepDuration {
+ return maxRetrySleepDuration
+ }
+ return delay
+}
+
func sleepWithCtx(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
diff --git a/pkg/utils/http_retry_test.go b/pkg/utils/http_retry_test.go
index d64cd5eda..4d6021ff7 100644
--- a/pkg/utils/http_retry_test.go
+++ b/pkg/utils/http_retry_test.go
@@ -80,6 +80,81 @@ func TestDoRequestWithRetry(t *testing.T) {
}
}
+func TestDoRequestWithRetry_RetryAfter429Honored(t *testing.T) {
+ retryDelayUnit = 10 * time.Millisecond
+ t.Cleanup(func() { retryDelayUnit = time.Second })
+
+ attempts := 0
+ var firstAttemptAt time.Time
+ var secondAttemptAt time.Time
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ attempts++
+ if attempts == 1 {
+ firstAttemptAt = time.Now()
+ w.Header().Set("Retry-After", "1")
+ w.WriteHeader(http.StatusTooManyRequests)
+ return
+ }
+ if attempts == 2 {
+ secondAttemptAt = time.Now()
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ client := &http.Client{Timeout: 5 * time.Second}
+ req, err := http.NewRequest(http.MethodGet, server.URL, nil)
+ require.NoError(t, err)
+
+ resp, err := DoRequestWithRetry(client, req)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ resp.Body.Close()
+ require.Equal(t, 2, attempts)
+
+ assert.GreaterOrEqual(t, secondAttemptAt.Sub(firstAttemptAt), 900*time.Millisecond)
+}
+
+func TestDoRequestWithRetry_RetryAfter429InvalidFallsBack(t *testing.T) {
+ retryDelayUnit = 50 * time.Millisecond
+ t.Cleanup(func() { retryDelayUnit = time.Second })
+
+ attempts := 0
+ var firstAttemptAt time.Time
+ var secondAttemptAt time.Time
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ attempts++
+ if attempts == 1 {
+ firstAttemptAt = time.Now()
+ w.Header().Set("Retry-After", "invalid")
+ w.WriteHeader(http.StatusTooManyRequests)
+ return
+ }
+ if attempts == 2 {
+ secondAttemptAt = time.Now()
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ client := &http.Client{Timeout: 5 * time.Second}
+ req, err := http.NewRequest(http.MethodGet, server.URL, nil)
+ require.NoError(t, err)
+
+ resp, err := DoRequestWithRetry(client, req)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ resp.Body.Close()
+ require.Equal(t, 2, attempts)
+
+ assert.GreaterOrEqual(t, secondAttemptAt.Sub(firstAttemptAt), 45*time.Millisecond)
+ assert.Less(t, secondAttemptAt.Sub(firstAttemptAt), 500*time.Millisecond)
+}
+
func TestDoRequestWithRetry_ContextCancel(t *testing.T) {
// Use a long retry delay so cancellation always hits during sleepWithCtx.
retryDelayUnit = 10 * time.Second
@@ -204,3 +279,87 @@ func TestDoRequestWithRetry_Delay(t *testing.T) {
assert.GreaterOrEqual(t, delays[2], time.Millisecond)
}
+
+func TestRetryDelayForAttempt_DateRetryAfterUsesResponseDateHeader(t *testing.T) {
+ maxRetrySleepDuration = time.Minute
+ t.Cleanup(func() { maxRetrySleepDuration = time.Minute })
+
+ serverDate := time.Date(2000, 1, 2, 15, 4, 5, 0, time.UTC)
+ retryAfterAt := serverDate.Add(10 * time.Second)
+ resp := &http.Response{
+ StatusCode: http.StatusTooManyRequests,
+ Header: http.Header{
+ "Retry-After": []string{retryAfterAt.Format(http.TimeFormat)},
+ "Date": []string{serverDate.Format(http.TimeFormat)},
+ },
+ }
+
+ assert.Equal(t, 10*time.Second, retryDelayForAttempt(resp, 0))
+}
+
+func TestRetryDelayForAttempt_DateRetryAfterInvalidOrMissingDateFallsBackSafely(t *testing.T) {
+ maxRetrySleepDuration = 30 * time.Second
+ t.Cleanup(func() { maxRetrySleepDuration = time.Minute })
+
+ retryAfterAt := time.Now().UTC().Add(3 * time.Second).Format(http.TimeFormat)
+ testcases := []struct {
+ name string
+ header http.Header
+ }{
+ {
+ name: "invalid-date-header",
+ header: http.Header{
+ "Retry-After": []string{retryAfterAt},
+ "Date": []string{"invalid-date"},
+ },
+ },
+ {
+ name: "missing-date-header",
+ header: http.Header{
+ "Retry-After": []string{retryAfterAt},
+ },
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ resp := &http.Response{
+ StatusCode: http.StatusTooManyRequests,
+ Header: tc.header,
+ }
+
+ delay := retryDelayForAttempt(resp, 0)
+ assert.Greater(t, delay, time.Duration(0))
+ assert.GreaterOrEqual(t, delay, 1500*time.Millisecond)
+ assert.LessOrEqual(t, delay, 5*time.Second)
+ })
+ }
+}
+
+func TestRetryDelayForAttempt_RetryAfterIsCapped(t *testing.T) {
+ maxRetrySleepDuration = 2 * time.Second
+ t.Cleanup(func() { maxRetrySleepDuration = time.Minute })
+
+ resp := &http.Response{
+ StatusCode: http.StatusTooManyRequests,
+ Header: http.Header{
+ "Retry-After": []string{"999999"},
+ },
+ }
+
+ assert.Equal(t, 2*time.Second, retryDelayForAttempt(resp, 0))
+}
+
+func TestRetryDelayForAttempt_RetryAfterNumericOverflowStillCaps(t *testing.T) {
+ maxRetrySleepDuration = 2 * time.Second
+ t.Cleanup(func() { maxRetrySleepDuration = time.Minute })
+
+ resp := &http.Response{
+ StatusCode: http.StatusTooManyRequests,
+ Header: http.Header{
+ "Retry-After": []string{"9223372036854775807"},
+ },
+ }
+
+ assert.Equal(t, 2*time.Second, retryDelayForAttempt(resp, 0))
+}
diff --git a/pkg/utils/tool_feedback.go b/pkg/utils/tool_feedback.go
new file mode 100644
index 000000000..1834d7f78
--- /dev/null
+++ b/pkg/utils/tool_feedback.go
@@ -0,0 +1,90 @@
+package utils
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+const ToolFeedbackContinuationHint = "Continuing the current task."
+
+func FormatArgsJSON(args map[string]any, prettyPrint, disableEscapeHTML bool) string {
+ // Normalize nil to empty map for consistent output
+ if args == nil {
+ args = map[string]any{}
+ }
+
+ var buf bytes.Buffer
+ enc := json.NewEncoder(&buf)
+ if prettyPrint {
+ enc.SetIndent("", " ")
+ }
+ if disableEscapeHTML {
+ enc.SetEscapeHTML(false)
+ }
+ if err := enc.Encode(args); err != nil {
+ // Fallback to fmt.Sprintf to preserve visibility of problematic args
+ return fmt.Sprintf("%v", args)
+ }
+ return strings.TrimSpace(buf.String())
+}
+
+// FormatToolFeedbackMessage renders a tool feedback message for chat channels.
+// It keeps the tool name on the first line for animation and can include both
+// a human explanation and the serialized tool arguments in the body.
+func FormatToolFeedbackMessage(toolName, explanation, argsPreview string) string {
+ toolName = strings.TrimSpace(toolName)
+ explanation = strings.TrimSpace(explanation)
+ argsPreview = strings.TrimSpace(argsPreview)
+
+ bodyLines := make([]string, 0, 2)
+ if explanation != "" {
+ bodyLines = append(bodyLines, explanation)
+ }
+ if argsPreview != "" {
+ bodyLines = append(bodyLines, "```json\n"+argsPreview+"\n```")
+ }
+ body := strings.Join(bodyLines, "\n")
+
+ if toolName == "" {
+ return body
+ }
+ if body == "" {
+ return fmt.Sprintf("\U0001f527 `%s`", toolName)
+ }
+
+ return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, body)
+}
+
+// FitToolFeedbackMessage keeps tool feedback within a single outbound message.
+// It preserves the first line when possible and truncates the explanation body
+// instead of letting the message be split into multiple chunks.
+func FitToolFeedbackMessage(content string, maxLen int) string {
+ content = strings.TrimSpace(content)
+ if content == "" || maxLen <= 0 {
+ return ""
+ }
+ if len([]rune(content)) <= maxLen {
+ return content
+ }
+
+ firstLine, rest, hasRest := strings.Cut(content, "\n")
+ firstLine = strings.TrimSpace(firstLine)
+ rest = strings.TrimSpace(rest)
+
+ if !hasRest || rest == "" {
+ return Truncate(firstLine, maxLen)
+ }
+
+ if len([]rune(firstLine)) >= maxLen {
+ return Truncate(firstLine, maxLen)
+ }
+
+ remaining := maxLen - len([]rune(firstLine)) - 1
+ if remaining <= 0 {
+ return Truncate(firstLine, maxLen)
+ }
+
+ return firstLine + "\n" + Truncate(rest, remaining)
+}
diff --git a/pkg/utils/tool_feedback_dedupe.go b/pkg/utils/tool_feedback_dedupe.go
new file mode 100644
index 000000000..b1adb60eb
--- /dev/null
+++ b/pkg/utils/tool_feedback_dedupe.go
@@ -0,0 +1,39 @@
+package utils
+
+import (
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func normalizeToolFeedbackComparisonText(text string) string {
+ text = strings.ReplaceAll(text, "\r\n", "\n")
+ text = strings.ReplaceAll(text, "\r", "\n")
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return ""
+ }
+ return strings.Join(strings.Fields(text), " ")
+}
+
+func ToolCallExplanationDuplicatesContent(content string, toolCalls []providers.ToolCall) bool {
+ normalizedContent := normalizeToolFeedbackComparisonText(content)
+ if normalizedContent == "" || len(toolCalls) == 0 {
+ return false
+ }
+
+ for _, tc := range toolCalls {
+ if tc.ExtraContent == nil {
+ continue
+ }
+ explanation := normalizeToolFeedbackComparisonText(tc.ExtraContent.ToolFeedbackExplanation)
+ if explanation == "" {
+ continue
+ }
+ if explanation == normalizedContent {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/pkg/utils/tool_feedback_dedupe_test.go b/pkg/utils/tool_feedback_dedupe_test.go
new file mode 100644
index 000000000..cc587080f
--- /dev/null
+++ b/pkg/utils/tool_feedback_dedupe_test.go
@@ -0,0 +1,55 @@
+package utils
+
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func TestToolCallExplanationDuplicatesContent(t *testing.T) {
+ t.Run("exact duplicate", func(t *testing.T) {
+ toolCalls := []providers.ToolCall{{
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: "Read the file before replying.",
+ },
+ }}
+
+ if !ToolCallExplanationDuplicatesContent("Read the file before replying.", toolCalls) {
+ t.Fatal("expected duplicated content to be detected")
+ }
+ })
+
+ t.Run("whitespace normalized duplicate", func(t *testing.T) {
+ toolCalls := []providers.ToolCall{{
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: "Read the file\nbefore replying.",
+ },
+ }}
+
+ if !ToolCallExplanationDuplicatesContent(" Read the file before replying. ", toolCalls) {
+ t.Fatal("expected whitespace-only differences to be ignored")
+ }
+ })
+
+ t.Run("distinct content", func(t *testing.T) {
+ toolCalls := []providers.ToolCall{{
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: "Read the file before replying.",
+ },
+ }}
+
+ if ToolCallExplanationDuplicatesContent(
+ "I will summarize the findings after reading the file.",
+ toolCalls,
+ ) {
+ t.Fatal("expected distinct content to remain visible")
+ }
+ })
+
+ t.Run("missing explanation", func(t *testing.T) {
+ toolCalls := []providers.ToolCall{{}}
+ if ToolCallExplanationDuplicatesContent("Read the file before replying.", toolCalls) {
+ t.Fatal("expected empty tool explanations to skip dedupe")
+ }
+ })
+}
diff --git a/pkg/utils/tool_feedback_test.go b/pkg/utils/tool_feedback_test.go
new file mode 100644
index 000000000..da4accce4
--- /dev/null
+++ b/pkg/utils/tool_feedback_test.go
@@ -0,0 +1,156 @@
+package utils
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestFormatToolFeedbackMessage(t *testing.T) {
+ got := FormatToolFeedbackMessage(
+ "read_file",
+ "I will read README.md first to confirm the current project structure.",
+ "{\n \"path\": \"README.md\"\n}",
+ )
+ want := "\U0001f527 `read_file`\nI will read README.md first to confirm the current project structure.\n```json\n{\n \"path\": \"README.md\"\n}\n```"
+ if got != want {
+ t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatToolFeedbackMessage_EmptyExplanationShowsArgs(t *testing.T) {
+ got := FormatToolFeedbackMessage("read_file", "", "{\n \"path\": \"README.md\"\n}")
+ want := "\U0001f527 `read_file`\n```json\n{\n \"path\": \"README.md\"\n}\n```"
+ if got != want {
+ t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) {
+ got := FormatToolFeedbackMessage("", "Continue drafting the final response.", "")
+ want := "Continue drafting the final response."
+ if got != want {
+ t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatToolFeedbackMessage_EmptyExplanationAndArgsKeepsOnlyToolLine(t *testing.T) {
+ got := FormatToolFeedbackMessage("read_file", "", "")
+ want := "\U0001f527 `read_file`"
+ if got != want {
+ t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
+ }
+}
+
+func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) {
+ got := FitToolFeedbackMessage(
+ "\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.",
+ 40,
+ )
+ want := "\U0001f527 `read_file`\nRead README.md first to..."
+ if got != want {
+ t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want)
+ }
+}
+
+func TestFitToolFeedbackMessage_TruncatesSingleLineMessage(t *testing.T) {
+ got := FitToolFeedbackMessage("\U0001f527 `read_file`", 10)
+ want := "\U0001f527 `read..."
+ if got != want {
+ t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatArgsJSON_Defaults(t *testing.T) {
+ args := map[string]any{"path": "README.md", "line": 42}
+ got := FormatArgsJSON(args, false, false)
+ var gotVal, wantVal any
+ if err := json.Unmarshal([]byte(got), &gotVal); err != nil {
+ t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err)
+ }
+ want := `{"path":"README.md","line":42}`
+ if err := json.Unmarshal([]byte(want), &wantVal); err != nil {
+ t.Fatalf("invalid test want JSON: %v", err)
+ }
+ if !jsonValEq(gotVal, wantVal) {
+ t.Fatalf("FormatArgsJSON() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatArgsJSON_PrettyPrint(t *testing.T) {
+ args := map[string]any{"path": "README.md", "line": 42}
+ got := FormatArgsJSON(args, true, false)
+ var gotVal any
+ if err := json.Unmarshal([]byte(got), &gotVal); err != nil {
+ t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err)
+ }
+ want := `{"path":"README.md","line":42}`
+ var wantVal any
+ if err := json.Unmarshal([]byte(want), &wantVal); err != nil {
+ t.Fatalf("invalid test want JSON: %v", err)
+ }
+ if !jsonValEq(gotVal, wantVal) {
+ t.Fatalf("FormatArgsJSON() prettyPrint = %q, want structure %q", got, want)
+ }
+}
+
+func TestFormatArgsJSON_DisableEscapeHTML(t *testing.T) {
+ args := map[string]any{"msg": "a < b && c > d"}
+ got := FormatArgsJSON(args, false, true)
+ var gotVal, wantVal any
+ want := `{"msg":"a < b && c > d"}`
+ if err := json.Unmarshal([]byte(got), &gotVal); err != nil {
+ t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err)
+ }
+ if err := json.Unmarshal([]byte(want), &wantVal); err != nil {
+ t.Fatalf("invalid test want JSON: %v", err)
+ }
+ if !jsonValEq(gotVal, wantVal) {
+ t.Fatalf("FormatArgsJSON() disableEscapeHTML = %q, want %q", got, want)
+ }
+}
+
+func TestFormatArgsJSON_PrettyPrintAndDisableEscapeHTML(t *testing.T) {
+ args := map[string]any{"msg": "a < b && c > d"}
+ got := FormatArgsJSON(args, true, true)
+ var gotVal, wantVal any
+ want := `{"msg":"a < b && c > d"}`
+ if err := json.Unmarshal([]byte(got), &gotVal); err != nil {
+ t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err)
+ }
+ if err := json.Unmarshal([]byte(want), &wantVal); err != nil {
+ t.Fatalf("invalid test want JSON: %v", err)
+ }
+ if !jsonValEq(gotVal, wantVal) {
+ t.Fatalf("FormatArgsJSON() combined = %q, want %q", got, want)
+ }
+}
+
+func TestFormatArgsJSON_EscapeHTMLByDefault(t *testing.T) {
+ args := map[string]any{"msg": "a < b && c > d"}
+ got := FormatArgsJSON(args, false, false)
+ var gotVal, wantVal any
+ want := `{"msg":"a \u003c b \u0026\u0026 c \u003e d"}`
+ if err := json.Unmarshal([]byte(got), &gotVal); err != nil {
+ t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err)
+ }
+ if err := json.Unmarshal([]byte(want), &wantVal); err != nil {
+ t.Fatalf("invalid test want JSON: %v", err)
+ }
+ if !jsonValEq(gotVal, wantVal) {
+ t.Fatalf("FormatArgsJSON() default escape = %q, want %q", got, want)
+ }
+}
+
+func TestFormatArgsJSON_NilArgs(t *testing.T) {
+ got := FormatArgsJSON(nil, false, false)
+ want := `{}`
+ if got != want {
+ t.Fatalf("FormatArgsJSON() nil = %q, want %q", got, want)
+ }
+}
+
+func jsonValEq(a, b any) bool {
+ aJSON, _ := json.Marshal(a)
+ bJSON, _ := json.Marshal(b)
+ return string(aJSON) == string(bJSON)
+}
diff --git a/pkg/utils/visible_tool_calls.go b/pkg/utils/visible_tool_calls.go
new file mode 100644
index 000000000..8c4d89a51
--- /dev/null
+++ b/pkg/utils/visible_tool_calls.go
@@ -0,0 +1,106 @@
+package utils
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+type VisibleToolCall struct {
+ ID string `json:"id,omitempty"`
+ Type string `json:"type,omitempty"`
+ Function *VisibleToolCallFunction `json:"function,omitempty"`
+ ExtraContent *VisibleToolCallExtraContent `json:"extra_content,omitempty"`
+}
+
+type VisibleToolCallFunction struct {
+ Name string `json:"name,omitempty"`
+ Arguments string `json:"arguments,omitempty"`
+}
+
+type VisibleToolCallExtraContent struct {
+ ToolFeedbackExplanation string `json:"tool_feedback_explanation,omitempty"`
+}
+
+func BuildVisibleToolCalls(
+ toolCalls []providers.ToolCall,
+ maxArgsLen int,
+) []VisibleToolCall {
+ if len(toolCalls) == 0 {
+ return nil
+ }
+
+ visible := make([]VisibleToolCall, 0, len(toolCalls))
+ for _, tc := range toolCalls {
+ name, _ := VisibleToolCallNameAndArguments(tc)
+ argsPreview := VisibleToolCallArgumentsPreview(tc, maxArgsLen)
+ explanation := ""
+ if tc.ExtraContent != nil {
+ explanation = strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation)
+ }
+ if name == "" && explanation == "" && argsPreview == "" {
+ continue
+ }
+
+ visibleCall := VisibleToolCall{
+ ID: strings.TrimSpace(tc.ID),
+ Type: strings.TrimSpace(tc.Type),
+ }
+ if visibleCall.Type == "" {
+ visibleCall.Type = "function"
+ }
+ if name != "" || argsPreview != "" {
+ visibleCall.Function = &VisibleToolCallFunction{
+ Name: name,
+ Arguments: argsPreview,
+ }
+ }
+ if explanation != "" {
+ visibleCall.ExtraContent = &VisibleToolCallExtraContent{
+ ToolFeedbackExplanation: explanation,
+ }
+ }
+
+ visible = append(visible, visibleCall)
+ }
+
+ if len(visible) == 0 {
+ return nil
+ }
+ return visible
+}
+
+func VisibleToolCallNameAndArguments(tc providers.ToolCall) (string, string) {
+ name := strings.TrimSpace(tc.Name)
+ argsJSON := ""
+ if tc.Function != nil {
+ if name == "" {
+ name = strings.TrimSpace(tc.Function.Name)
+ }
+ argsJSON = strings.TrimSpace(tc.Function.Arguments)
+ }
+ if argsJSON == "" && len(tc.Arguments) > 0 {
+ if encodedArgs, err := json.Marshal(tc.Arguments); err == nil {
+ argsJSON = string(encodedArgs)
+ }
+ }
+ return name, strings.TrimSpace(argsJSON)
+}
+
+func VisibleToolCallArgumentsPreview(tc providers.ToolCall, maxLen int) string {
+ _, argsJSON := VisibleToolCallNameAndArguments(tc)
+ if argsJSON == "" {
+ return ""
+ }
+
+ var pretty bytes.Buffer
+ if err := json.Indent(&pretty, []byte(argsJSON), "", " "); err == nil {
+ argsJSON = pretty.String()
+ }
+ if maxLen > 0 {
+ return Truncate(argsJSON, maxLen)
+ }
+ return argsJSON
+}
diff --git a/pkg/utils/visible_tool_calls_test.go b/pkg/utils/visible_tool_calls_test.go
new file mode 100644
index 000000000..fe9467c57
--- /dev/null
+++ b/pkg/utils/visible_tool_calls_test.go
@@ -0,0 +1,33 @@
+package utils
+
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func TestBuildVisibleToolCalls_DoesNotTruncateExplanation(t *testing.T) {
+ explanation := "Read README.md first to confirm the current project structure before editing the config example."
+ toolCalls := []providers.ToolCall{{
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}`,
+ },
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: explanation,
+ },
+ }}
+
+ visible := BuildVisibleToolCalls(toolCalls, 20)
+ if len(visible) != 1 {
+ t.Fatalf("len(visible) = %d, want 1", len(visible))
+ }
+ if visible[0].ExtraContent == nil || visible[0].ExtraContent.ToolFeedbackExplanation != explanation {
+ t.Fatalf("visible explanation = %#v, want %q", visible[0].ExtraContent, explanation)
+ }
+ if visible[0].Function == nil || visible[0].Function.Arguments == "" {
+ t.Fatalf("visible function = %#v, want truncated args preview", visible[0].Function)
+ }
+}
diff --git a/pkg/voice/elevenlabs_transcriber_test.go b/pkg/voice/elevenlabs_transcriber_test.go
deleted file mode 100644
index 78be8958a..000000000
--- a/pkg/voice/elevenlabs_transcriber_test.go
+++ /dev/null
@@ -1,83 +0,0 @@
-package voice
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "testing"
-)
-
-// Ensure ElevenLabsTranscriber satisfies the Transcriber interface at compile time.
-var _ Transcriber = (*ElevenLabsTranscriber)(nil)
-
-func TestElevenLabsTranscriberName(t *testing.T) {
- tr := NewElevenLabsTranscriber("sk_test")
- if got := tr.Name(); got != "elevenlabs" {
- t.Errorf("Name() = %q, want %q", got, "elevenlabs")
- }
-}
-
-func TestElevenLabsTranscribe(t *testing.T) {
- tmpDir := t.TempDir()
- audioPath := filepath.Join(tmpDir, "clip.ogg")
- if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil {
- t.Fatalf("failed to write fake audio file: %v", err)
- }
-
- t.Run("success", func(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/v1/speech-to-text" {
- t.Errorf("unexpected path: %s", r.URL.Path)
- }
- if r.Header.Get("Xi-Api-Key") != "sk_test" {
- t.Errorf("unexpected xi-api-key header: %s", r.Header.Get("Xi-Api-Key"))
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(TranscriptionResponse{
- Text: "hello from elevenlabs",
- Language: "en",
- })
- }))
- defer srv.Close()
-
- tr := NewElevenLabsTranscriber("sk_test")
- tr.apiBase = srv.URL
-
- resp, err := tr.Transcribe(context.Background(), audioPath)
- if err != nil {
- t.Fatalf("Transcribe() error: %v", err)
- }
- if resp.Text != "hello from elevenlabs" {
- t.Errorf("Text = %q, want %q", resp.Text, "hello from elevenlabs")
- }
- if resp.Language != "en" {
- t.Errorf("Language = %q, want %q", resp.Language, "en")
- }
- })
-
- t.Run("api error", func(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized)
- }))
- defer srv.Close()
-
- tr := NewElevenLabsTranscriber("sk_bad")
- tr.apiBase = srv.URL
-
- _, err := tr.Transcribe(context.Background(), audioPath)
- if err == nil {
- t.Fatal("expected error for non-200 response, got nil")
- }
- })
-
- t.Run("missing file", func(t *testing.T) {
- tr := NewElevenLabsTranscriber("sk_test")
- _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
- if err == nil {
- t.Fatal("expected error for missing file, got nil")
- }
- })
-}
diff --git a/pkg/voice/groq_transcriber.go b/pkg/voice/groq_transcriber.go
deleted file mode 100644
index b42e598f7..000000000
--- a/pkg/voice/groq_transcriber.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package voice
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "mime/multipart"
- "net/http"
- "os"
- "path/filepath"
- "time"
-
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/utils"
-)
-
-type GroqTranscriber struct {
- apiKey string
- apiBase string
- httpClient *http.Client
-}
-
-func NewGroqTranscriber(apiKey string) *GroqTranscriber {
- logger.DebugCF("voice", "Creating Groq transcriber", map[string]any{"has_api_key": apiKey != ""})
-
- apiBase := "https://api.groq.com/openai/v1"
- return &GroqTranscriber{
- apiKey: apiKey,
- apiBase: apiBase,
- httpClient: &http.Client{
- Timeout: 60 * time.Second,
- },
- }
-}
-
-func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
- logger.InfoCF("voice", "Starting transcription", map[string]any{"audio_file": audioFilePath})
-
- audioFile, err := os.Open(audioFilePath)
- if err != nil {
- logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err})
- return nil, fmt.Errorf("failed to open audio file: %w", err)
- }
- defer audioFile.Close()
-
- fileInfo, err := audioFile.Stat()
- if err != nil {
- logger.ErrorCF("voice", "Failed to get file info", map[string]any{"path": audioFilePath, "error": err})
- return nil, fmt.Errorf("failed to get file info: %w", err)
- }
-
- logger.DebugCF("voice", "Audio file details", map[string]any{
- "size_bytes": fileInfo.Size(),
- "file_name": filepath.Base(audioFilePath),
- })
-
- var requestBody bytes.Buffer
- writer := multipart.NewWriter(&requestBody)
-
- part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath))
- if err != nil {
- logger.ErrorCF("voice", "Failed to create form file", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to create form file: %w", err)
- }
-
- copied, err := io.Copy(part, audioFile)
- if err != nil {
- logger.ErrorCF("voice", "Failed to copy file content", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to copy file content: %w", err)
- }
-
- logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied})
-
- if err = writer.WriteField("model", "whisper-large-v3"); err != nil {
- logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to write model field: %w", err)
- }
-
- if err = writer.WriteField("response_format", "json"); err != nil {
- logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to write response_format field: %w", err)
- }
-
- if err = writer.Close(); err != nil {
- logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to close multipart writer: %w", err)
- }
-
- url := t.apiBase + "/audio/transcriptions"
- req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody)
- if err != nil {
- logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Content-Type", writer.FormDataContentType())
- req.Header.Set("Authorization", "Bearer "+t.apiKey)
-
- logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]any{
- "url": url,
- "request_size_bytes": requestBody.Len(),
- "file_size_bytes": fileInfo.Size(),
- })
-
- resp, err := t.httpClient.Do(req)
- if err != nil {
- logger.ErrorCF("voice", "Failed to send request", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to send request: %w", err)
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- logger.ErrorCF("voice", "Failed to read response", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to read response: %w", err)
- }
-
- if resp.StatusCode != http.StatusOK {
- logger.ErrorCF("voice", "API error", map[string]any{
- "status_code": resp.StatusCode,
- "response": string(body),
- })
- return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
- }
-
- logger.DebugCF("voice", "Received response from Groq API", map[string]any{
- "status_code": resp.StatusCode,
- "response_size_bytes": len(body),
- })
-
- var result TranscriptionResponse
- if err := json.Unmarshal(body, &result); err != nil {
- logger.ErrorCF("voice", "Failed to unmarshal response", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to unmarshal response: %w", err)
- }
-
- logger.InfoCF("voice", "Transcription completed successfully", map[string]any{
- "text_length": len(result.Text),
- "language": result.Language,
- "duration_seconds": result.Duration,
- "transcription_preview": utils.Truncate(result.Text, 50),
- })
-
- return &result, nil
-}
-
-func (t *GroqTranscriber) Name() string {
- return "groq"
-}
diff --git a/pkg/voice/groq_transcriber_test.go b/pkg/voice/groq_transcriber_test.go
deleted file mode 100644
index fdcaa7580..000000000
--- a/pkg/voice/groq_transcriber_test.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package voice
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "testing"
-)
-
-var _ Transcriber = (*GroqTranscriber)(nil)
-
-func TestGroqTranscriberName(t *testing.T) {
- tr := NewGroqTranscriber("sk-test")
- if got := tr.Name(); got != "groq" {
- t.Errorf("Name() = %q, want %q", got, "groq")
- }
-}
-
-func TestGroqTranscribe(t *testing.T) {
- // Write a minimal fake audio file so the transcriber can open and send it.
- tmpDir := t.TempDir()
- audioPath := filepath.Join(tmpDir, "clip.ogg")
- if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil {
- t.Fatalf("failed to write fake audio file: %v", err)
- }
-
- t.Run("success", func(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/audio/transcriptions" {
- t.Errorf("unexpected path: %s", r.URL.Path)
- }
- if r.Header.Get("Authorization") != "Bearer sk-test" {
- t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization"))
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(TranscriptionResponse{
- Text: "hello world",
- Language: "en",
- Duration: 1.5,
- })
- }))
- defer srv.Close()
-
- tr := NewGroqTranscriber("sk-test")
- tr.apiBase = srv.URL
-
- resp, err := tr.Transcribe(context.Background(), audioPath)
- if err != nil {
- t.Fatalf("Transcribe() error: %v", err)
- }
- if resp.Text != "hello world" {
- t.Errorf("Text = %q, want %q", resp.Text, "hello world")
- }
- if resp.Language != "en" {
- t.Errorf("Language = %q, want %q", resp.Language, "en")
- }
- })
-
- t.Run("api error", func(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized)
- }))
- defer srv.Close()
-
- tr := NewGroqTranscriber("sk-bad")
- tr.apiBase = srv.URL
-
- _, err := tr.Transcribe(context.Background(), audioPath)
- if err == nil {
- t.Fatal("expected error for non-200 response, got nil")
- }
- })
-
- t.Run("missing file", func(t *testing.T) {
- tr := NewGroqTranscriber("sk-test")
- _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
- if err == nil {
- t.Fatal("expected error for missing file, got nil")
- }
- })
-}
diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go
deleted file mode 100644
index f56fdeedd..000000000
--- a/pkg/voice/transcriber.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package voice
-
-import (
- "context"
- "strings"
-
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/providers"
-)
-
-type Transcriber interface {
- Name() string
- Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
-}
-
-type TranscriptionResponse struct {
- Text string `json:"text"`
- Language string `json:"language,omitempty"`
- Duration float64 `json:"duration,omitempty"`
-}
-
-func supportsAudioTranscription(model string) bool {
- protocol, _ := providers.ExtractProtocol(model)
-
- switch protocol {
- case "openai", "azure", "azure-openai",
- "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
- "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
- "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
- "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita",
- "coding-plan", "alibaba-coding", "qwen-coding":
- // These protocols all go through the OpenAI-compatible or Azure provider path in
- // providers.CreateProviderFromConfig, so they are the only ones that can supply
- // the audio media payload shape expected by NewAudioModelTranscriber.
-
- // TODO: Further restrict this by modelID, since not every model under these
- // protocols supports audio transcription.
- return true
- default:
- return false
- }
-}
-
-// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or
-// nil if no supported transcription provider is configured.
-func DetectTranscriber(cfg *config.Config) Transcriber {
- if modelName := strings.TrimSpace(cfg.Voice.ModelName); modelName != "" {
- modelCfg, err := cfg.GetModelConfig(modelName)
- if err != nil {
- return nil
- }
- if supportsAudioTranscription(modelCfg.Model) {
- return NewAudioModelTranscriber(modelCfg)
- }
- }
-
- // ElevenLabs voice config (supports Scribe STT).
- if key := strings.TrimSpace(cfg.Voice.ElevenLabsAPIKey); key != "" {
- return NewElevenLabsTranscriber(key)
- }
- // Fall back to any model-list entry that uses the groq/ protocol.
- for _, mc := range cfg.ModelList {
- if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey() != "" {
- return NewGroqTranscriber(mc.APIKey())
- }
- }
- return nil
-}
diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh
index 76cc72938..df2100aec 100755
--- a/scripts/build-macos-app.sh
+++ b/scripts/build-macos-app.sh
@@ -10,6 +10,8 @@ if [ -z "$EXECUTABLE" ]; then
exit 1
fi
+LAUNCHER_EXECUTABLE="picoclaw-launcher-${EXECUTABLE}"
+EXECUTABLE="picoclaw-${EXECUTABLE}"
echo "executable: $EXECUTABLE"
APP_NAME="PicoClaw Launcher"
@@ -33,17 +35,17 @@ mkdir -p "$APP_RESOURCES"
# Copy executable
echo "Copying executable..."
-if [ -f "./web/build/${APP_EXECUTABLE}" ]; then
- cp "./web/build/${APP_EXECUTABLE}" "${APP_MACOS}/"
+if [ -f "./build/${LAUNCHER_EXECUTABLE}" ]; then
+ cp "./build/${LAUNCHER_EXECUTABLE}" "${APP_MACOS}/${APP_EXECUTABLE}"
else
- echo "Error: ./web/build/${APP_EXECUTABLE} not found. Please build the web backend first."
- echo "Run: make build in web dir"
+ echo "Error: ./build/${LAUNCHER_EXECUTABLE} not found. Please build the web backend first."
+ echo "Run: make build-launcher"
exit 1
fi
-if [ -f "./build/picoclaw" ]; then
- cp "./build/picoclaw" "${APP_MACOS}/"
+if [ -f "./build/${EXECUTABLE}" ]; then
+ cp "./build/${EXECUTABLE}" "${APP_MACOS}/picoclaw"
else
- echo "Error: ./build/picoclaw not found. Please build the main file first."
+ echo "Error: ./build/${EXECUTABLE} not found. Please build the main file first."
echo "Run: make build"
exit 1
fi
@@ -76,10 +78,10 @@ cat > "${APP_CONTENTS}/Info.plist" << 'EOF'
NSSupportsAutomaticGraphicsSwitching
- LSRequiresCarbon
-
LSUIElement
- 1
+
+ LSMinimumSystemVersion
+ 10.11
EOF
diff --git a/scripts/copydir.go b/scripts/copydir.go
new file mode 100644
index 000000000..6e2777612
--- /dev/null
+++ b/scripts/copydir.go
@@ -0,0 +1,186 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+)
+
+func main() {
+ if len(os.Args) != 3 {
+ fmt.Fprintf(os.Stderr, "usage: go run scripts/copydir.go \n")
+ os.Exit(2)
+ }
+
+ repoRoot, err := findRepoRoot()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "locate repo root: %v\n", err)
+ os.Exit(1)
+ }
+
+ src, err := normalizePathArg(os.Args[1], repoRoot)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "resolve src path: %v\n", err)
+ os.Exit(1)
+ }
+
+ dst, err := normalizePathArg(os.Args[2], repoRoot)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "resolve dst path: %v\n", err)
+ os.Exit(1)
+ }
+
+ if err := ensurePathWithinRepo(repoRoot, src); err != nil {
+ fmt.Fprintf(os.Stderr, "invalid src path: %v\n", err)
+ os.Exit(1)
+ }
+ if err := ensurePathWithinRepo(repoRoot, dst); err != nil {
+ fmt.Fprintf(os.Stderr, "invalid dst path: %v\n", err)
+ os.Exit(1)
+ }
+ if samePath(repoRoot, dst) {
+ fmt.Fprintln(os.Stderr, "invalid dst path: destination cannot be repo root")
+ os.Exit(1)
+ }
+
+ if err := os.RemoveAll(dst); err != nil {
+ fmt.Fprintf(os.Stderr, "remove %s: %v\n", dst, err)
+ os.Exit(1)
+ }
+
+ if err := copyTree(src, dst); err != nil {
+ fmt.Fprintf(os.Stderr, "copy %s -> %s: %v\n", src, dst, err)
+ os.Exit(1)
+ }
+}
+
+func findRepoRoot() (string, error) {
+ _, file, _, ok := runtime.Caller(0)
+ if !ok {
+ return "", fmt.Errorf("unable to locate copydir.go source path")
+ }
+
+ scriptDir := filepath.Dir(file)
+ candidate := filepath.Clean(filepath.Join(scriptDir, ".."))
+ if err := validateRepoRoot(candidate); err == nil {
+ return candidate, nil
+ }
+
+ wd, err := os.Getwd()
+ if err != nil {
+ return "", err
+ }
+
+ cur, err := filepath.Abs(wd)
+ if err != nil {
+ return "", err
+ }
+
+ for {
+ if err := validateRepoRoot(cur); err == nil {
+ return filepath.Clean(cur), nil
+ }
+ parent := filepath.Dir(cur)
+ if parent == cur {
+ return "", fmt.Errorf("could not find repository root from %s", wd)
+ }
+ cur = parent
+ }
+}
+
+func validateRepoRoot(root string) error {
+ anchors := []string{
+ filepath.Join(root, "go.sum"),
+ filepath.Join(root, "LICENSE"),
+ filepath.Join(root, ".github"),
+ }
+ for _, anchor := range anchors {
+ if _, err := os.Stat(anchor); err != nil {
+ return fmt.Errorf("missing repo anchor %s: %w", anchor, err)
+ }
+ }
+ return nil
+}
+
+func normalizePathArg(arg, repoRoot string) (string, error) {
+ resolved := strings.ReplaceAll(arg, "${codespace}", repoRoot)
+ abs, err := filepath.Abs(resolved)
+ if err != nil {
+ return "", err
+ }
+ return filepath.Clean(abs), nil
+}
+
+func ensurePathWithinRepo(repoRoot, path string) error {
+ rel, err := filepath.Rel(repoRoot, path)
+ if err != nil {
+ return err
+ }
+ if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return fmt.Errorf("path %s is outside repository root %s", path, repoRoot)
+ }
+ return nil
+}
+
+func samePath(a, b string) bool {
+ return filepath.Clean(a) == filepath.Clean(b)
+}
+
+func copyTree(src, dst string) error {
+ info, err := os.Stat(src)
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() {
+ return fmt.Errorf("source is not a directory: %s", src)
+ }
+
+ return filepath.Walk(src, func(path string, entry os.FileInfo, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+
+ rel, err := filepath.Rel(src, path)
+ if err != nil {
+ return err
+ }
+
+ target := dst
+ if rel != "." {
+ target = filepath.Join(dst, rel)
+ }
+
+ if entry.IsDir() {
+ return os.MkdirAll(target, entry.Mode())
+ }
+
+ return copyFile(path, target, entry.Mode())
+ })
+}
+
+func copyFile(src, dst string, mode os.FileMode) error {
+ if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+ return err
+ }
+
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+
+ out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ if _, err := io.Copy(out, in); err != nil {
+ return err
+ }
+
+ return out.Close()
+}
diff --git a/scripts/lint-docs.sh b/scripts/lint-docs.sh
new file mode 100755
index 000000000..7351298b6
--- /dev/null
+++ b/scripts/lint-docs.sh
@@ -0,0 +1,219 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+cd "$(git rev-parse --show-toplevel)"
+
+failures=0
+
+error() {
+ local path="$1"
+ local reason="$2"
+ local suggestion="${3:-}"
+
+ echo "docs lint: $path" >&2
+ echo " reason: $reason" >&2
+ if [[ -n "$suggestion" ]]; then
+ echo " fix: $suggestion" >&2
+ fi
+ failures=1
+}
+
+lowercase() {
+ printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
+}
+
+suggest_noncanonical_translation_name() {
+ local path="$1"
+ local dir
+ local base
+ local stem
+ local locale
+
+ dir="$(dirname "$path")"
+ base="$(basename "$path")"
+
+ if [[ "$base" =~ ^(.+)_([A-Za-z]{2}(-[A-Za-z]{2})?)\.md$ ]]; then
+ stem="${BASH_REMATCH[1]}"
+ locale="$(lowercase "${BASH_REMATCH[2]}")"
+ printf '%s/%s.%s.md' "$dir" "$stem" "$locale"
+ return
+ fi
+
+ if [[ "$base" =~ ^(.+)\.([A-Za-z]{2}(-[A-Za-z]{2})?)\.md$ ]]; then
+ stem="${BASH_REMATCH[1]}"
+ locale="$(lowercase "${BASH_REMATCH[2]}")"
+ printf '%s/%s.%s.md' "$dir" "$stem" "$locale"
+ return
+ fi
+
+ printf 'rename it to use a lowercase ..md suffix beside the English source'
+}
+
+suggest_docs_language_bucket_target() {
+ local path="$1"
+ local locale
+ local file
+ local name
+ local -a matches
+
+ if [[ "$path" =~ ^docs/([A-Za-z]{2}(-[A-Za-z]{2})?)/.+\.md$ ]]; then
+ locale="$(lowercase "${BASH_REMATCH[1]}")"
+ file="$(basename "$path")"
+ name="${file%.md}"
+ mapfile -t matches < <(find docs/project docs/guides docs/reference docs/operations docs/security docs/architecture docs/channels docs/design docs/migration -type f -name "${name}.md" 2>/dev/null | sort)
+ if [[ "${#matches[@]}" -eq 1 ]]; then
+ printf '%s' "${matches[0]%.md}.${locale}.md"
+ return
+ fi
+ fi
+
+ printf 'move it to a typed docs directory and rename it to ..md beside the English source'
+}
+
+suggest_nested_locale_bucket_target() {
+ local path="$1"
+ local prefix
+ local locale
+ local rest
+
+ if [[ "$path" =~ ^(docs/(project|guides|reference|operations|security|architecture|design|migration))/([A-Za-z]{2}(-[A-Za-z]{2})?)/(.*)\.md$ ]]; then
+ prefix="${BASH_REMATCH[1]}"
+ locale="$(lowercase "${BASH_REMATCH[3]}")"
+ rest="${BASH_REMATCH[5]}"
+ printf '%s/%s.%s.md' "$prefix" "$rest" "$locale"
+ return
+ fi
+
+ if [[ "$path" =~ ^(docs/channels/[^/]+)/([A-Za-z]{2}(-[A-Za-z]{2})?)/(.*)\.md$ ]]; then
+ prefix="${BASH_REMATCH[1]}"
+ locale="$(lowercase "${BASH_REMATCH[2]}")"
+ rest="${BASH_REMATCH[4]}"
+ printf '%s/%s.%s.md' "$prefix" "$rest" "$locale"
+ return
+ fi
+
+ printf 'move the file beside its English source and rename it to ..md'
+}
+
+is_noncanonical_translation_name() {
+ local path="$1"
+ local base
+
+ base="$(basename "$path")"
+
+ [[ "$base" =~ ^.+_[A-Za-z]{2}(-[A-Za-z]{2})?\.md$ ]] && return 0
+ [[ "$base" =~ ^.+\.[A-Z]{2}(-[A-Z]{2})?\.md$ ]] && return 0
+ [[ "$base" =~ ^.+\.[a-z]{2}-[A-Z]{2}\.md$ ]] && return 0
+ [[ "$base" =~ ^.+\.[A-Z]{2}-[a-z]{2}\.md$ ]] && return 0
+
+ return 1
+}
+
+is_noncanonical_locale_bucket() {
+ local path="$1"
+
+ [[ "$path" =~ ^docs/(project|guides|reference|operations|security|architecture|design|migration)/[A-Za-z]{2}(-[A-Za-z]{2})?/ ]] && return 0
+ [[ "$path" =~ ^docs/channels/[^/]+/[A-Za-z]{2}(-[A-Za-z]{2})?/ ]] && return 0
+ return 1
+}
+
+is_root_docs_language_bucket() {
+ local path="$1"
+ [[ "$path" =~ ^docs/[A-Za-z]{2}(-[A-Za-z]{2})?/ ]]
+}
+
+is_translation_file() {
+ local path="$1"
+ [[ "$path" =~ ^(.+)\.([a-z]{2})(-[a-z]{2})?\.md$ ]]
+}
+
+translation_base() {
+ local path="$1"
+ local locale="$2"
+
+ if [[ "$path" == docs/project/* ]]; then
+ local rel="${path#docs/project/}"
+ echo "${rel%.$locale.md}.md"
+ return
+ fi
+
+ echo "${path%.$locale.md}.md"
+}
+
+while IFS= read -r path; do
+ [[ -f "$path" ]] || continue
+
+ case "$path" in
+ README.*.md)
+ error \
+ "$path" \
+ "translated project entry docs must live under docs/project/" \
+ "move it to docs/project/$(basename "$path")"
+ ;;
+ CONTRIBUTING.*.md)
+ error \
+ "$path" \
+ "translated project entry docs must live under docs/project/" \
+ "move it to docs/project/$(basename "$path")"
+ ;;
+ esac
+
+ if [[ "$path" =~ (^|/)README_[A-Za-z0-9-]+\.md$ ]]; then
+ error \
+ "$path" \
+ "legacy README translation names are not allowed" \
+ "rename it to use README..md, for example $(suggest_noncanonical_translation_name "$path")"
+ fi
+
+ if is_noncanonical_translation_name "$path"; then
+ error \
+ "$path" \
+ "translation files must use lowercase ..md suffixes and no underscore variants" \
+ "rename it to $(suggest_noncanonical_translation_name "$path")"
+ fi
+
+ if is_root_docs_language_bucket "$path"; then
+ error \
+ "$path" \
+ "language bucket directories under docs/ are not allowed" \
+ "move it to $(suggest_docs_language_bucket_target "$path")"
+ fi
+
+ if is_noncanonical_locale_bucket "$path"; then
+ error \
+ "$path" \
+ "translations must live beside the English source, not under locale-named subdirectories" \
+ "move it to $(suggest_nested_locale_bucket_target "$path")"
+ fi
+
+ if [[ "$path" =~ ^docs/[^/]+\.md$ && "$path" != "docs/README.md" ]]; then
+ error \
+ "$path" \
+ "top-level docs Markdown files must move into a typed docs/ subdirectory" \
+ "move it into one of docs/project/, docs/guides/, docs/reference/, docs/operations/, docs/security/, docs/architecture/, docs/channels/, docs/design/, or docs/migration/"
+ fi
+
+ if is_translation_file "$path"; then
+ locale="${BASH_REMATCH[2]}${BASH_REMATCH[3]}"
+
+ if [[ "$path" == docs/design/* ]]; then
+ continue
+ fi
+
+ base="$(translation_base "$path" "$locale")"
+ if [[ ! -f "$base" ]]; then
+ error \
+ "$path" \
+ "missing English source document '$base'" \
+ "add the English source document at '$base' or move this translation beside the correct English source"
+ fi
+ fi
+done < <(git ls-files --cached --others --exclude-standard -- '*.md')
+
+if [[ "$failures" -ne 0 ]]; then
+ echo "docs lint: failed" >&2
+ exit 1
+fi
+
+echo "docs lint: OK"
diff --git a/web/Makefile b/web/Makefile
index 06717f2b9..254c439e9 100644
--- a/web/Makefile
+++ b/web/Makefile
@@ -1,25 +1,74 @@
-.PHONY: dev dev-frontend dev-backend build test lint clean
+.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean \
+ build-android-arm64 build-android-bundle
# Go variables
-GO?=CGO_ENABLED=0 go
+GO?=go
WEB_GO?=$(GO)
-GOFLAGS?=-v -tags stdjson
+CGO_ENABLED?=0
+GO_BUILD_TAGS?=goolm,stdjson
+GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
+GOCACHE?=$(abspath ../.cache/go-build)
+GOMODCACHE?=$(abspath ../.cache/go-mod)
+GOTOOLCHAIN?=local
+export CGO_ENABLED
+export GOCACHE
+export GOMODCACHE
+export GOTOOLCHAIN
# Build variables
BUILD_DIR=build
+EXT=
+OUTPUT?=$(BUILD_DIR)/picoclaw-launcher$(EXT)
+OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64$(EXT)
+FRONTEND_DIR=frontend
+FRONTEND_INSTALL_STAMP=$(FRONTEND_DIR)/node_modules/.picoclaw-install-stamp
+BACKEND_DIR=backend
+BACKEND_DIST=$(BACKEND_DIR)/dist
+PICOCLAW_BINARY_NAME=picoclaw
+PICOCLAW_BINARY?=$(abspath ../build/$(PICOCLAW_BINARY_NAME))
+LAUNCHER_GUI_LDFLAG=
+
+ifeq ($(OS),Windows_NT)
+ POWERSHELL=powershell -NoProfile -Command
+ WINDOWS_GOARCH_RAW:=$(strip $(shell go env GOARCH 2>NUL))
+endif
# Version
-VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
-GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
-BUILD_TIME=$(shell date +%FT%T%z)
-GO_VERSION=$(shell $(WEB_GO) version | awk '{print $$3}')
+ifeq ($(OS),Windows_NT)
+ VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>NUL))
+ GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>NUL))
+ BUILD_TIME_RAW:=$(strip $(shell powershell -NoProfile -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK'"))
+ GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>NUL))
+else
+ VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>/dev/null))
+ GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>/dev/null))
+ BUILD_TIME_RAW:=$(strip $(shell date +%FT%T%z))
+ GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>/dev/null))
+endif
+VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev)
+GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev)
+BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev)
+GO_VERSION=$(if $(GO_VERSION_RAW),$(GO_VERSION_RAW),unknown)
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
# OS detection
-UNAME_S:=$(shell uname -s)
-UNAME_M:=$(shell uname -m)
+ifeq ($(OS),Windows_NT)
+ UNAME_S=Windows
+ ifeq ($(WINDOWS_GOARCH_RAW),amd64)
+ UNAME_M=x86_64
+ else ifeq ($(WINDOWS_GOARCH_RAW),arm64)
+ UNAME_M=arm64
+ else ifeq ($(WINDOWS_GOARCH_RAW),386)
+ UNAME_M=x86
+ else
+ UNAME_M=$(if $(WINDOWS_GOARCH_RAW),$(WINDOWS_GOARCH_RAW),x86_64)
+ endif
+else
+ UNAME_S:=$(shell uname -s)
+ UNAME_M:=$(shell uname -m)
+endif
# Platform-specific settings
ifeq ($(UNAME_S),Linux)
@@ -51,46 +100,110 @@ else ifeq ($(UNAME_S),Darwin)
endif
else ifeq ($(UNAME_S),Windows)
PLATFORM=windows
- ARCH=$(UNAME_M)
- LDFLAGS=-H=windowsgui $(LDFLAGS)
+ ifeq ($(UNAME_M),x86_64)
+ ARCH=amd64
+ else ifeq ($(UNAME_M),arm64)
+ ARCH=arm64
+ else
+ ARCH=$(UNAME_M)
+ endif
+ EXT=.exe
+ PICOCLAW_BINARY_NAME=picoclaw.exe
+ LAUNCHER_GUI_LDFLAG=-H=windowsgui
else
PLATFORM=$(UNAME_S)
ARCH=$(UNAME_M)
endif
+LAUNCHER_LDFLAGS=$(strip $(LAUNCHER_GUI_LDFLAG) $(LDFLAGS))
+
# Run both frontend and backend dev servers
-dev:
- @if [ ! -f $(BUILD_DIR)/picoclaw-launcher ] || [ ! -d backend/dist ]; then \
- echo "Build artifacts not found, building..."; \
- $(MAKE) build; \
+dev: build-dev-picoclaw
+ @if [ ! -f "$(BACKEND_DIST)/index.html" ]; then \
+ echo "Embedded frontend not found, building..."; \
+ $(MAKE) build-frontend; \
fi
@echo "Starting backend and frontend dev servers..."
- @$(MAKE) dev-backend & $(MAKE) dev-frontend
+ @$(MAKE) dev-backend BACKEND_ARGS='-no-browser' & $(MAKE) dev-frontend
# Start frontend dev server (Vite, with proxy to backend)
dev-frontend:
- cd frontend && pnpm dev
+ cd $(FRONTEND_DIR) && pnpm dev
# Start backend dev server
dev-backend:
- cd backend && ${WEB_GO} run -ldflags "$(LDFLAGS)" .
+ cd $(BACKEND_DIR) && PICOCLAW_BINARY="$(PICOCLAW_BINARY)" ${WEB_GO} run -ldflags "$(LAUNCHER_LDFLAGS)" . $(BACKEND_ARGS)
# Build frontend and embed into Go binary
-build:
- cd frontend && pnpm build:backend
- ${WEB_GO} build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/picoclaw-launcher ./backend/
+build: build-frontend
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path (Split-Path -Parent '$(OUTPUT)') | Out-Null"
+else
+ @mkdir -p "$$(dirname "$(OUTPUT)")"
+endif
+ ${WEB_GO} build $(GOFLAGS) -ldflags "$(LAUNCHER_LDFLAGS)" -o "$(OUTPUT)" ./$(BACKEND_DIR)/
+
+# Build launcher for Android ARM64 (frontend must already be built)
+build-android-arm64: build-frontend
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
+else
+ @mkdir -p $(BUILD_DIR)
+endif
+ GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(OUTPUT_ANDROID_ARM64)" ./$(BACKEND_DIR)/
+
+# Build launcher for all Android architectures
+build-android-bundle: build-frontend
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
+else
+ @mkdir -p $(BUILD_DIR)
+endif
+ GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/picoclaw-launcher-android-arm64" ./$(BACKEND_DIR)/
+ @echo "All Android launcher builds complete"
+
+build-frontend:
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "if ((-not (Test-Path -LiteralPath '$(FRONTEND_DIR)/node_modules')) -or (-not (Test-Path -LiteralPath '$(FRONTEND_DIR)/node_modules/.bin/tsc')) -or (-not (Test-Path -LiteralPath '$(FRONTEND_INSTALL_STAMP)')) -or ((Get-Content -LiteralPath '$(FRONTEND_INSTALL_STAMP)' -Raw).Trim() -ne (((Get-FileHash -LiteralPath '$(FRONTEND_DIR)/package.json' -Algorithm SHA256).Hash + ':' + (Get-FileHash -LiteralPath '$(FRONTEND_DIR)/pnpm-lock.yaml' -Algorithm SHA256).Hash)))) { Write-Host 'Installing frontend dependencies...'; Push-Location '$(FRONTEND_DIR)'; try { pnpm install --frozen-lockfile } finally { Pop-Location }; Set-Content -LiteralPath '$(FRONTEND_INSTALL_STAMP)' -Value (((Get-FileHash -LiteralPath '$(FRONTEND_DIR)/package.json' -Algorithm SHA256).Hash + ':' + (Get-FileHash -LiteralPath '$(FRONTEND_DIR)/pnpm-lock.yaml' -Algorithm SHA256).Hash)) -NoNewline }"
+else
+ @expected_stamp="$$(cat $(FRONTEND_DIR)/package.json $(FRONTEND_DIR)/pnpm-lock.yaml | cksum | awk '{print $$1 ":" $$2}')"; \
+ if [ ! -d $(FRONTEND_DIR)/node_modules ] || \
+ [ ! -x $(FRONTEND_DIR)/node_modules/.bin/tsc ] || \
+ [ ! -f $(FRONTEND_INSTALL_STAMP) ] || \
+ [ "$$(cat $(FRONTEND_INSTALL_STAMP) 2>/dev/null)" != "$$expected_stamp" ]; then \
+ echo "Installing frontend dependencies..."; \
+ (cd $(FRONTEND_DIR) && CI=true pnpm install --frozen-lockfile) && \
+ printf '%s\n' "$$expected_stamp" > $(FRONTEND_INSTALL_STAMP); \
+ fi
+endif
+ @echo "Building frontend..."
+ @cd $(FRONTEND_DIR) && pnpm build:backend
+
+build-dev-picoclaw:
+ @echo "Building picoclaw for launcher development..."
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path (Split-Path -Parent '$(PICOCLAW_BINARY)') | Out-Null"
+else
+ @mkdir -p "$$(dirname "$(PICOCLAW_BINARY)")"
+endif
+ @$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw
# Run all tests
test:
- cd backend && ${WEB_GO} test ./...
- cd frontend && pnpm lint
+ cd $(BACKEND_DIR) && ${WEB_GO} test ./...
+ cd $(FRONTEND_DIR) && pnpm lint
# Lint and format
lint:
- cd backend && ${WEB_GO} vet ./...
- cd frontend && pnpm check
+ cd $(BACKEND_DIR) && ${WEB_GO} vet ./...
+ cd $(FRONTEND_DIR) && pnpm check
# Clean build artifacts
clean:
- rm -rf frontend/dist backend/dist $(BUILD_DIR)
- mkdir -p backend/dist && touch backend/dist/.gitkeep
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "$$paths=@('$(FRONTEND_DIR)/dist','$(BACKEND_DIST)','$(BUILD_DIR)'); foreach($$p in $$paths){ if (Test-Path -LiteralPath $$p) { Remove-Item -LiteralPath $$p -Recurse -Force } }"
+ @node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs
+else
+ rm -rf $(FRONTEND_DIR)/dist $(BACKEND_DIST) $(BUILD_DIR)
+ node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs
+endif
diff --git a/web/README.md b/web/README.md
index 6ec247bae..2a57524e0 100644
--- a/web/README.md
+++ b/web/README.md
@@ -1,51 +1,367 @@
-# Picoclaw Web
+# PicoClaw Web
-This directory contains the standalone web service for `picoclaw`.
-It provides a complete unified web interface, acting as a dashboard, configuration center, and interactive console (channel client) for the core `picoclaw` engine.
+`web/` contains the standalone WebUI launcher for PicoClaw.
+It is not just a frontend: it is a small launcher service that bundles a React dashboard, exposes a backend API, manages launcher authentication, and starts or attaches to the `picoclaw gateway` process.
+
+
+
+## What This Directory Provides
+
+- A browser-based chat UI backed by the Pico channel WebSocket proxy.
+- A dashboard for models, credentials, channels, agent tools, skills, logs, and runtime settings.
+- A launcher process that can auto-open the browser, show a system tray menu, and persist launcher-specific settings.
+- A controlled way to start, stop, restart, and inspect the `picoclaw gateway` subprocess.
+- A single-binary deployment target where the frontend is embedded into the Go backend.
## Architecture
-The service is structured as a monorepo containing both the backend and frontend code to ensure high cohesion and simplify deployment.
+This directory is a small monorepo:
-* **`backend/`**: The Go-based web server. It provides RESTful APIs, manages WebSocket connections for chat, and handles the lifecycle of the `picoclaw` process. It eventually embeds the compiled frontend assets into a single executable.
-* **`frontend/`**: The Vite + React + TanStack Router single-page application (SPA). It provides the interactive user interface.
+- `backend/`
+ - Go HTTP server and launcher runtime.
+ - Serves REST APIs, authentication endpoints, channel helper flows, and the Pico WebSocket reverse proxy.
+ - Embeds compiled frontend assets from `backend/dist`.
+- `frontend/`
+ - Vite + React 19 + TanStack Router SPA.
+ - Provides the launcher dashboard and chat UI.
-## Getting Started
+At runtime the launcher and the main PicoClaw engine are separate processes:
+
+1. The launcher starts the web backend on port `18800` by default.
+2. The launcher serves the dashboard and handles dashboard authentication.
+3. When allowed, it starts or attaches to `picoclaw gateway -E`.
+4. The frontend talks only to the launcher backend.
+5. The launcher proxies chat traffic to the gateway through `/pico/ws`.
+
+## Dashboard Capabilities
+
+The current frontend exposes these major pages and flows:
+
+- `/`
+ - Chat UI with session history, default model selection, and Pico channel messaging.
+- `/models`
+ - Add, edit, delete, and set the default model.
+ - Supports API-key models, OAuth-backed models, and local/CLI-backed models.
+- `/credentials`
+ - Manage provider credentials.
+ - Current built-in flows: OpenAI, Anthropic, and Google Antigravity.
+- `/channels/*`
+ - Configure supported channels from a shared catalog.
+ - Current catalog: `weixin`, `telegram`, `discord`, `slack`, `feishu`, `dingtalk`, `line`, `qq`, `onebot`, `wecom`, `whatsapp`, `whatsapp_native`, `pico`, `maixcam`, `matrix`, `irc`.
+ - Includes QR-based binding helpers for WeChat and WeCom.
+- `/agent/skills`
+ - Browse built-in, global, and workspace skills.
+ - Import Markdown skills into the workspace and delete workspace-owned skills.
+- `/agent/tools`
+ - View tool availability and enable or disable tool switches through config-backed APIs.
+- `/config`
+ - Edit agent defaults, exec controls, cron controls, heartbeat, device monitoring, launcher networking, and launch-at-login settings.
+- `/logs`
+ - View the in-memory gateway log buffer and clear it.
+
+The UI currently supports English and Simplified Chinese, plus light and dark themes.
+
+## Runtime Behavior
+
+### Config Resolution
+
+The launcher uses the same PicoClaw config file as the main binary.
+
+- Default app config path: `~/.picoclaw/config.json`
+- Override with environment variable: `PICOCLAW_CONFIG`
+- Override with a positional CLI argument: `picoclaw-launcher /path/to/config.json`
+
+Launcher-only settings are stored beside that app config:
+
+- File name: `launcher-config.json`
+- Default location: `~/.picoclaw/launcher-config.json`
+
+That file currently stores:
+
+- `port`
+- `public`
+- `allowed_cidrs`
+
+If `-port` or `-public` are passed explicitly, the CLI flag wins for that run.
+If they are omitted, stored launcher settings are used.
+
+### First-Run Onboarding
+
+If the target config file does not exist, the launcher tries to bootstrap it automatically by running:
+
+```bash
+picoclaw onboard
+```
+
+The launcher looks for the main PicoClaw binary in this order:
+
+1. `PICOCLAW_BINARY`
+2. A `picoclaw` binary in the same directory as the launcher
+3. `picoclaw` from `PATH`
+
+If onboarding or gateway startup cannot find the main binary, set `PICOCLAW_BINARY` explicitly.
+
+### Gateway Management
+
+The launcher manages `picoclaw gateway -E`.
+
+On startup it tries to auto-start or attach to the gateway, but only when startup preconditions pass. In the current code, the main checks are:
+
+- a default model is configured
+- the default model entry is valid
+- the default model has usable credentials
+- local/runtime-probed models are reachable
+
+When a gateway process is started by the launcher, the launcher:
+
+- captures stdout and stderr into an in-memory ring buffer
+- tracks transient states such as `starting`, `restarting`, and `stopping`
+- marks restart-required when the default model or enabled tool set changed since boot
+- ensures the Pico channel is configured before startup
+
+### Launcher Authentication
+
+The dashboard is protected by password login.
+
+- First run uses `/launcher-setup` to create the dashboard password.
+- Manual login uses `/launcher-login`.
+- Successful login sets an HttpOnly session cookie.
+- Existing sessions are invalidated when the launcher process restarts; otherwise the browser cookie expires after 31 days.
+- When the launcher auto-opens a local browser after startup, it uses a one-shot loopback-only bootstrap endpoint to set the session cookie automatically.
+- On supported platforms, the password is stored as a bcrypt hash in `launcher-auth.db`.
+- On platforms where the SQLite password store is unavailable, the launcher stores the bcrypt hash in `launcher-config.json`.
+- Legacy `launcher_token` values are migrated once into password login and are removed from saved launcher config.
+- `PICOCLAW_LAUNCHER_TOKEN` is deprecated and ignored; after upgrading from env-token auth, open `/launcher-setup` to create a password.
+- URL token login and `Authorization: Bearer` dashboard auth are not supported.
+
+### Network Exposure
+
+By default the launcher listens on:
+
+```text
+127.0.0.1:18800
+```
+
+With `-public` or `public: true`, it listens on all interfaces:
+
+```text
+0.0.0.0:18800
+```
+
+When public access is enabled:
+
+- the launcher still protects the dashboard with password login
+- optional `allowed_cidrs` can restrict which client IP ranges may connect
+- the gateway host is overridden so remote clients can still use the launcher-managed proxy paths
+
+## Build And Run
### Prerequisites
-* Go 1.25+
-* Node.js 20+ with pnpm
+- Go `1.25+`
+- Node.js 20.19+ or 22.13+
+- `pnpm`
-### Development
+On macOS, the `web` Makefile enables `CGO_ENABLED=1` so tray-enabled launcher builds work as expected.
+On Darwin or FreeBSD without cgo, the launcher falls back to headless mode without a tray.
-Run both the frontend dev server and the Go backend simultaneously:
+If you want to prepare the frontend workspace manually, you can still install dependencies yourself:
+
+```bash
+cd frontend
+pnpm install
+```
+
+### Recommended Development Workflow
+
+From the `web/` directory:
```bash
make dev
```
-Or run them separately:
+This does three things:
+
+1. Builds `../build/picoclaw` for launcher development.
+2. Starts the Go backend with `PICOCLAW_BINARY` pointing at that binary.
+3. Starts the Vite frontend dev server.
+
+Use this when you want the full launcher flow during development.
+
+### Run Frontend And Backend Separately
```bash
-make dev-frontend # Vite dev server
-make dev-backend # Go backend
+make dev-frontend
+make dev-backend
```
-### Build
+Notes:
-Build the frontend and embed it into a single Go binary:
+- `dev-frontend` runs the Vite server.
+- `dev-backend` runs the Go backend only.
+- The Vite dev server proxies `/api` to `http://localhost:18800`.
+- Chat WebSocket URLs are generated by the backend, so the frontend does not hardcode gateway addresses.
+- Running `dev-backend` alone is mainly useful for backend work or when `backend/dist` already contains a built frontend.
+
+### Build The Standalone Launcher Binary
+
+From `web/`:
```bash
make build
```
-The output binary is `backend/picoclaw-web`.
+This:
-### Other Commands
+1. Installs frontend dependencies when needed.
+2. Builds the frontend into `backend/dist`.
+3. Embeds those assets into the Go backend.
+4. Produces `build/picoclaw-launcher`.
+
+Override the output path if needed:
```bash
-make test # Run backend tests and frontend lint
-make lint # Run go vet and prettier/eslint
-make clean # Remove all build artifacts
+make build OUTPUT=/tmp/picoclaw-launcher
```
+
+From the repository root you can also use:
+
+```bash
+make build-launcher
+```
+
+That writes the platform-specific launcher to:
+
+```text
+build/picoclaw-launcher--
+```
+
+and refreshes the `build/picoclaw-launcher` symlink.
+
+### Frontend-Only Builds
+
+For frontend work there are two useful package scripts:
+
+```bash
+cd frontend
+pnpm build
+pnpm build:backend
+```
+
+- `pnpm build` writes a normal Vite build to `frontend/dist`
+- `pnpm build:backend` writes the embeddable build to `../backend/dist`
+
+### Run The Built Launcher
+
+Examples:
+
+```bash
+./build/picoclaw-launcher
+./build/picoclaw-launcher -console
+./build/picoclaw-launcher -public
+./build/picoclaw-launcher -port 19999 /path/to/config.json
+```
+
+Current launcher flags:
+
+- `-port`
+- `-public`
+- `-no-browser`
+- `-lang`
+- `-console`
+
+## Make Targets
+
+From `web/`:
+
+```bash
+make dev
+make dev-frontend
+make dev-backend
+make build
+make build-frontend
+make test
+make lint
+make clean
+```
+
+What they do today:
+
+- `make build-frontend`
+ - Runs `pnpm install --frozen-lockfile` when dependencies are missing or stale.
+ - Builds the embeddable frontend into `backend/dist`.
+- `make test`
+ - Runs backend Go tests.
+ - Runs frontend `pnpm lint`.
+- `make lint`
+ - Runs backend `go vet`.
+ - Runs frontend `pnpm check`.
+ - `pnpm check` currently formats files with Prettier and fixes lint issues with ESLint, so this target can modify your working tree.
+- `make clean`
+ - Removes `frontend/dist`, `backend/dist`, and `build/`, then recreates `backend/dist/.gitkeep`.
+
+## Directory Layout
+
+```text
+web/
+├── backend/
+│ ├── api/ # REST API handlers and launcher runtime endpoints
+│ ├── launcherconfig/ # launcher-config.json load/save/validation
+│ ├── middleware/ # auth, content type, logging, CIDR allowlist
+│ ├── model/ # Go data structures and logic wrappers
+│ ├── utils/ # runtime helpers, onboarding, browser launch
+│ ├── winres/ # Windows application resources
+│ └── dist/ # embedded frontend build output
+├── frontend/
+│ ├── src/api/ # browser API clients
+│ ├── src/components/ # UI pages and shared components
+│ ├── src/features/ # feature-specific state, controllers, and protocol helpers
+│ ├── src/hooks/ # shared React hooks
+│ ├── src/i18n/ # internationalization language packs
+│ ├── src/lib/ # generic library utilities
+│ ├── src/routes/ # TanStack file routes
+│ ├── src/store/ # global state management
+│ └── vite.config.ts # dev server and build config
+├── Makefile
+└── README.md
+```
+
+## Troubleshooting
+
+### You have to sign in again after the launcher restarts
+
+Existing dashboard sessions do not survive launcher restarts.
+That is expected: each launcher process generates a new session value, so old cookies become invalid.
+Sign in again with the dashboard password on `/launcher-login`.
+
+### "Start Gateway" stays disabled
+
+The launcher only allows gateway startup when the configured default model is usable.
+Check these in the dashboard:
+
+- a default model is selected
+- the model has credentials or OAuth state
+- local models such as Ollama or vLLM are reachable
+
+### The launcher cannot find `picoclaw`
+
+Set the main binary explicitly:
+
+```bash
+export PICOCLAW_BINARY=/absolute/path/to/picoclaw
+```
+
+This affects onboarding and gateway subprocess startup.
+
+### The backend starts but the UI is blank in development
+
+Use `make dev` for the normal workflow.
+If you run only `make dev-backend`, either run `make dev-frontend` alongside it or build the embedded frontend first with `make build-frontend`.
+
+## Related Docs
+
+- Main project overview: [`../README.md`](../README.md)
+- Configuration guide: [`../docs/guides/configuration.md`](../docs/guides/configuration.md)
+- Providers: [`../docs/guides/providers.md`](../docs/guides/providers.md)
+- Troubleshooting: [`../docs/operations/troubleshooting.md`](../docs/operations/troubleshooting.md)
+- Official docs site: [docs.picoclaw.io](https://docs.picoclaw.io)
diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go
index b9b4d5f66..da07b76c0 100644
--- a/web/backend/api/auth.go
+++ b/web/backend/api/auth.go
@@ -1,8 +1,10 @@
package api
import (
+ "context"
"crypto/subtle"
"encoding/json"
+ "fmt"
"io"
"net/http"
"strings"
@@ -10,58 +12,83 @@ import (
"github.com/sipeed/picoclaw/web/backend/middleware"
)
-// LauncherAuthRouteOpts configures dashboard token login handlers.
-type LauncherAuthRouteOpts struct {
- DashboardToken string
- SessionCookie string
- SecureCookie func(*http.Request) bool
- // TokenHelp is returned on unauthenticated /api/auth/status responses (no secrets).
- TokenHelp LauncherAuthTokenHelp
+// PasswordStore is the interface for dashboard password persistence.
+// Implemented by dashboardauth.Store and launcherconfig.PasswordStore.
+type PasswordStore interface {
+ IsInitialized(ctx context.Context) (bool, error)
+ SetPassword(ctx context.Context, plain string) error
+ VerifyPassword(ctx context.Context, plain string) (bool, error)
}
-// LauncherAuthTokenHelp tells the login UI where users can find the dashboard token.
-type LauncherAuthTokenHelp struct {
- EnvVarName string `json:"env_var_name"`
- LogFileAbs string `json:"log_file,omitempty"`
- TrayCopyMenu bool `json:"tray_copy_menu"`
- ConsoleStdout bool `json:"console_stdout"`
+// LauncherAuthRouteOpts configures dashboard auth handlers.
+type LauncherAuthRouteOpts struct {
+ SessionCookie string
+ SecureCookie func(*http.Request) bool
+ // PasswordStore enables password login. It must be non-nil for auth to work.
+ PasswordStore PasswordStore
+ // StoreError holds the error returned when opening the password store. When
+ // non-nil and PasswordStore is nil, auth endpoints fail closed with a
+ // recovery message.
+ StoreError error
}
type launcherAuthLoginBody struct {
- Token string `json:"token"`
+ Password string `json:"password"`
+}
+
+type launcherAuthSetupBody struct {
+ Password string `json:"password"`
+ Confirm string `json:"confirm"`
}
type launcherAuthStatusResponse struct {
- Authenticated bool `json:"authenticated"`
- TokenHelp *LauncherAuthTokenHelp `json:"token_help,omitempty"`
+ Authenticated bool `json:"authenticated"`
+ Initialized bool `json:"initialized"`
}
-// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status.
+// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status|setup.
func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) {
secure := opts.SecureCookie
if secure == nil {
secure = middleware.DefaultLauncherDashboardSecureCookie
}
h := &launcherAuthHandlers{
- token: opts.DashboardToken,
sessionCookie: opts.SessionCookie,
secureCookie: secure,
- tokenHelp: opts.TokenHelp,
+ store: opts.PasswordStore,
+ storeErr: opts.StoreError,
loginLimit: newLoginRateLimiter(),
}
mux.HandleFunc("POST /api/auth/login", h.handleLogin)
mux.HandleFunc("POST /api/auth/logout", h.handleLogout)
mux.HandleFunc("GET /api/auth/status", h.handleStatus)
+ mux.HandleFunc("POST /api/auth/setup", h.handleSetup)
}
type launcherAuthHandlers struct {
- token string
sessionCookie string
secureCookie func(*http.Request) bool
- tokenHelp LauncherAuthTokenHelp
+ store PasswordStore
+ storeErr error // set when the store failed to open; drives recovery messages
loginLimit *loginRateLimiter
}
+// isStoreInitialized safely queries the store.
+// Returns (false, err) on store errors — callers must treat this as a 5xx, not as
+// "uninitialized", to keep auth fail-closed.
+func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, error) {
+ if h.store == nil {
+ if h.storeErr != nil {
+ return false, fmt.Errorf(
+ "password store unavailable (%w); "+
+ "to recover, stop the application, reset dashboard password storage, and restart",
+ h.storeErr)
+ }
+ return false, fmt.Errorf("password store not configured")
+ }
+ return h.store.IsInitialized(ctx)
+}
+
func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var body launcherAuthLoginBody
@@ -76,10 +103,29 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques
_, _ = w.Write([]byte(`{"error":"too many login attempts"}`))
return
}
- in := strings.TrimSpace(body.Token)
- if len(in) != len(h.token) || subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) != 1 {
+ in := strings.TrimSpace(body.Password)
+
+ initialized, initErr := h.isStoreInitialized(r.Context())
+ if initErr != nil {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ writeErrorf(w, "%v", initErr)
+ return
+ }
+ if !initialized {
+ w.WriteHeader(http.StatusConflict)
+ _, _ = w.Write([]byte(`{"error":"password has not been set"}`))
+ return
+ }
+
+ ok, err := h.store.VerifyPassword(r.Context(), in)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ writeErrorf(w, "password verification failed: %v", err)
+ return
+ }
+ if !ok {
w.WriteHeader(http.StatusUnauthorized)
- _, _ = w.Write([]byte(`{"error":"invalid token"}`))
+ _, _ = w.Write([]byte(`{"error":"invalid password"}`))
return
}
@@ -120,23 +166,105 @@ func (h *launcherAuthHandlers) handleLogout(w http.ResponseWriter, r *http.Reque
func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
- ok := false
+ authed := false
if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
- ok = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
+ authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
}
- if ok {
- _, _ = w.Write([]byte(`{"authenticated":true}`))
+ initialized, initErr := h.isStoreInitialized(r.Context())
+ if initErr != nil {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ writeErrorf(w, "%v", initErr)
return
}
resp := launcherAuthStatusResponse{
- Authenticated: false,
- TokenHelp: &h.tokenHelp,
+ Authenticated: authed,
+ Initialized: initialized,
}
enc, err := json.Marshal(resp)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
- _, _ = w.Write([]byte(`{"error":"internal error"}`))
+ writeErrorf(w, "marshal response failed: %v", err)
return
}
_, _ = w.Write(enc)
}
+
+// handleSetup sets or changes the dashboard password.
+//
+// Rules:
+// - If the store has no password yet, anyone who can reach the setup endpoint
+// may initialize the password.
+// - If a password is already set, the caller must hold a valid session cookie.
+func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+
+ if h.store == nil {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ if h.storeErr != nil {
+ writeErrorf(w, "password store unavailable: %v", h.storeErr)
+ } else {
+ _, _ = w.Write([]byte(`{"error":"password store not configured"}`))
+ }
+ return
+ }
+
+ initialized, initErr := h.isStoreInitialized(r.Context())
+ if initErr != nil {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ writeErrorf(w, "%v", initErr)
+ return
+ }
+
+ // If already initialized, require an active session (change-password flow).
+ if initialized {
+ authed := false
+ if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
+ authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
+ }
+ if !authed {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"error":"must be authenticated to change password"}`))
+ return
+ }
+ }
+
+ var body launcherAuthSetupBody
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":"invalid JSON"}`))
+ return
+ }
+
+ pw := strings.TrimSpace(body.Password)
+ if pw == "" {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":"password must not be empty"}`))
+ return
+ }
+ if pw != strings.TrimSpace(body.Confirm) {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":"passwords do not match"}`))
+ return
+ }
+ if len([]rune(pw)) < 8 {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":"password must be at least 8 characters"}`))
+ return
+ }
+
+ if err := h.store.SetPassword(r.Context(), pw); err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ writeErrorf(w, "failed to save password: %v", err)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"status":"ok"}`))
+}
+
+// writeErrorf writes a JSON error response with a formatted message.
+// json.Marshal is used to safely escape the message string.
+func writeErrorf(w http.ResponseWriter, format string, args ...any) {
+ msg, _ := json.Marshal(fmt.Sprintf(format, args...))
+ _, _ = w.Write([]byte(`{"error":` + string(msg) + `}`))
+}
diff --git a/web/backend/api/auth_test.go b/web/backend/api/auth_test.go
index d2624a440..f7f6037a0 100644
--- a/web/backend/api/auth_test.go
+++ b/web/backend/api/auth_test.go
@@ -2,7 +2,9 @@ package api
import (
"bytes"
+ "context"
"encoding/json"
+ "errors"
"net/http"
"net/http/httptest"
"strings"
@@ -12,23 +14,43 @@ import (
"github.com/sipeed/picoclaw/web/backend/middleware"
)
-func TestLauncherAuthLoginAndStatus(t *testing.T) {
- key := make([]byte, 32)
- for i := range key {
- key[i] = 0x55
+type fakePasswordStore struct {
+ initialized bool
+ password string
+ err error
+}
+
+func (s *fakePasswordStore) IsInitialized(context.Context) (bool, error) {
+ if s.err != nil {
+ return false, s.err
}
- const tok = "dashboard-test-token-9"
- sess := middleware.SessionCookieValue(key, tok)
+ return s.initialized, nil
+}
+
+func (s *fakePasswordStore) SetPassword(_ context.Context, plain string) error {
+ if s.err != nil {
+ return s.err
+ }
+ s.password = plain
+ s.initialized = true
+ return nil
+}
+
+func (s *fakePasswordStore) VerifyPassword(_ context.Context, plain string) (bool, error) {
+ if s.err != nil {
+ return false, s.err
+ }
+ return s.initialized && plain == s.password, nil
+}
+
+func TestLauncherAuthLoginAndStatus(t *testing.T) {
+ const password = "dashboard-test-password"
+ const sess = "session-cookie-value"
+ store := &fakePasswordStore{initialized: true, password: password}
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
- DashboardToken: tok,
- SessionCookie: sess,
- TokenHelp: LauncherAuthTokenHelp{
- EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
- LogFileAbs: "/tmp/launcher.log",
- TrayCopyMenu: true,
- ConsoleStdout: false,
- },
+ SessionCookie: sess,
+ PasswordStore: store,
})
t.Run("status_unauthenticated", func(t *testing.T) {
@@ -38,23 +60,20 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
t.Fatalf("status code = %d", rec.Code)
}
var body struct {
- Authenticated bool `json:"authenticated"`
- TokenHelp *LauncherAuthTokenHelp `json:"token_help"`
+ Authenticated bool `json:"authenticated"`
+ Initialized bool `json:"initialized"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
- if body.Authenticated || body.TokenHelp == nil {
- t.Fatalf("unexpected body: %+v", body)
- }
- if body.TokenHelp.EnvVarName != "PICOCLAW_LAUNCHER_TOKEN" || body.TokenHelp.LogFileAbs != "/tmp/launcher.log" {
- t.Fatalf("token_help = %+v", body.TokenHelp)
+ if body.Authenticated {
+ t.Fatalf("unexpected authenticated=true: %+v", body)
}
})
t.Run("login_ok", func(t *testing.T) {
rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"token":"`+tok+`"}`))
+ req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+password+`"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:12345"
mux.ServeHTTP(rec, req)
@@ -84,14 +103,152 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
})
}
-func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
- key := make([]byte, 32)
- sess := middleware.SessionCookieValue(key, "tok")
+func TestLauncherAuthUninitializedStoreRequiresSetup(t *testing.T) {
+ const sess = "session-cookie-value"
+ store := &fakePasswordStore{}
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
- DashboardToken: "tok",
- SessionCookie: sess,
- TokenHelp: LauncherAuthTokenHelp{EnvVarName: "PICOCLAW_LAUNCHER_TOKEN"},
+ SessionCookie: sess,
+ PasswordStore: store,
+ })
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/status", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status code = %d body=%s", rec.Code, rec.Body.String())
+ }
+
+ var body struct {
+ Authenticated bool `json:"authenticated"`
+ Initialized bool `json:"initialized"`
+ }
+ if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if body.Initialized {
+ t.Fatalf("initialized = true, want false before setup")
+ }
+ if body.Authenticated {
+ t.Fatalf("unexpected authenticated=true: %+v", body)
+ }
+
+ rec = httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"not-set-yet"}`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusConflict {
+ t.Fatalf("login before setup code = %d body=%s", rec.Code, rec.Body.String())
+ }
+
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(
+ http.MethodPost,
+ "/api/auth/setup",
+ strings.NewReader(`{"password":"12345678","confirm":"12345678"}`),
+ )
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String())
+ }
+
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"12345678"}`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("login after setup code = %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestLauncherAuthSetupRequiresSessionWhenInitialized(t *testing.T) {
+ const sess = "session-cookie-value"
+ store := &fakePasswordStore{initialized: true, password: "old-password"}
+ mux := http.NewServeMux()
+ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
+ SessionCookie: sess,
+ PasswordStore: store,
+ })
+
+ body := strings.NewReader(`{"password":"new-password","confirm":"new-password"}`)
+ req := httptest.NewRequest(http.MethodPost, "/api/auth/setup", body)
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("setup without session code = %d body=%s", rec.Code, rec.Body.String())
+ }
+
+ body = strings.NewReader(`{"password":"new-password","confirm":"new-password"}`)
+ req = httptest.NewRequest(http.MethodPost, "/api/auth/setup", body)
+ req.Header.Set("Content-Type", "application/json")
+ req.AddCookie(&http.Cookie{Name: middleware.LauncherDashboardCookieName, Value: sess})
+ rec = httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("setup with session code = %d body=%s", rec.Code, rec.Body.String())
+ }
+ if store.password != "new-password" {
+ t.Fatalf("password = %q, want new-password", store.password)
+ }
+}
+
+func TestLauncherAuthInitialSetupAllowsDirectSetup(t *testing.T) {
+ store := &fakePasswordStore{}
+ mux := http.NewServeMux()
+ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
+ SessionCookie: "session-cookie-value",
+ PasswordStore: store,
+ })
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/api/auth/setup",
+ strings.NewReader(`{"password":"12345678","confirm":"12345678"}`),
+ )
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("setup without grant code = %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestLauncherAuthStoreUnavailableFailsClosed(t *testing.T) {
+ mux := http.NewServeMux()
+ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
+ SessionCookie: "session-cookie-value",
+ StoreError: errors.New("open auth store"),
+ })
+
+ for _, tc := range []struct {
+ name string
+ method string
+ path string
+ body string
+ }{
+ {name: "status", method: http.MethodGet, path: "/api/auth/status"},
+ {name: "login", method: http.MethodPost, path: "/api/auth/login", body: `{"password":"password"}`},
+ {name: "setup", method: http.MethodPost, path: "/api/auth/setup", body: `{"password":"12345678","confirm":"12345678"}`},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
+ if tc.body != "" {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("code = %d body=%s", rec.Code, rec.Body.String())
+ }
+ })
+ }
+}
+
+func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
+ mux := http.NewServeMux()
+ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
+ SessionCookie: "session-cookie-value",
})
rec := httptest.NewRecorder()
@@ -118,18 +275,15 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
}
func TestLauncherAuthLoginRateLimit(t *testing.T) {
- key := make([]byte, 32)
- const tok = "rate-limit-tok-xxxxxxxx"
- sess := middleware.SessionCookieValue(key, tok)
+ store := &fakePasswordStore{initialized: true, password: "correct-password"}
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
- DashboardToken: tok,
- SessionCookie: sess,
- TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
+ SessionCookie: "session-cookie-value",
+ PasswordStore: store,
})
- // 11 failing logins by wrong token; each consumes allow() slot after valid JSON.
- wrongBody := `{"token":"wrong"}`
+ // 11 failing logins by wrong password; each consumes allow() slot after valid JSON.
+ wrongBody := `{"password":"wrong"}`
for i := 0; i < loginAttemptsPerIP; i++ {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody))
@@ -181,13 +335,9 @@ func TestReferrerPolicyMiddleware(t *testing.T) {
}
func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
- key := make([]byte, 32)
- sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
- DashboardToken: "tok",
- SessionCookie: sess,
- TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
+ SessionCookie: "session-cookie-value",
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
@@ -200,13 +350,9 @@ func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
}
func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) {
- key := make([]byte, 32)
- sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
- DashboardToken: "tok",
- SessionCookie: sess,
- TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
+ SessionCookie: "session-cookie-value",
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`))
diff --git a/web/backend/api/channels.go b/web/backend/api/channels.go
index dd4c9af3d..82cd54b72 100644
--- a/web/backend/api/channels.go
+++ b/web/backend/api/channels.go
@@ -3,6 +3,8 @@ package api
import (
"encoding/json"
"net/http"
+
+ "github.com/sipeed/picoclaw/pkg/config"
)
type channelCatalogItem struct {
@@ -30,9 +32,17 @@ var channelCatalog = []channelCatalogItem{
{Name: "irc", ConfigKey: "irc"},
}
+type channelConfigResponse struct {
+ Config any `json:"config"`
+ ConfiguredSecrets []string `json:"configured_secrets"`
+ ConfigKey string `json:"config_key"`
+ Variant string `json:"variant,omitempty"`
+}
+
// registerChannelRoutes binds read-only channel catalog endpoints to the ServeMux.
func (h *Handler) registerChannelRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog)
+ mux.HandleFunc("GET /api/channels/{name}/config", h.handleGetChannelConfig)
}
// handleListChannelCatalog returns the channels supported by backend.
@@ -44,3 +54,150 @@ func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Reques
"channels": channelCatalog,
})
}
+
+// handleGetChannelConfig returns safe channel config plus secret presence metadata.
+//
+// GET /api/channels/{name}/config
+func (h *Handler) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) {
+ channelName := r.PathValue("name")
+ item, ok := findChannelCatalogItem(channelName)
+ if !ok {
+ http.Error(w, "Channel not found", http.StatusNotFound)
+ return
+ }
+
+ cfg, err := config.LoadConfig(h.configPath)
+ if err != nil {
+ http.Error(w, "Failed to load config", http.StatusInternalServerError)
+ return
+ }
+
+ resp := buildChannelConfigResponse(cfg, item)
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ }
+}
+
+func findChannelCatalogItem(name string) (channelCatalogItem, bool) {
+ for _, item := range channelCatalog {
+ if item.Name == name {
+ return item, true
+ }
+ }
+ return channelCatalogItem{}, false
+}
+
+var channelSecretFieldMap = map[string][]string{
+ "weixin": {"token"},
+ "telegram": {"token"},
+ "discord": {"token"},
+ "slack": {"bot_token", "app_token"},
+ "feishu": {"app_secret", "encrypt_key", "verification_token"},
+ "dingtalk": {"client_secret"},
+ "line": {"channel_secret", "channel_access_token"},
+ "qq": {"app_secret"},
+ "onebot": {"access_token"},
+ "wecom": {"secret"},
+ "pico": {"token"},
+ "matrix": {"access_token"},
+ "irc": {"password", "nickserv_password", "sasl_password"},
+ "whatsapp": {},
+ "whatsapp_native": {},
+ "maixcam": {},
+}
+
+func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) channelConfigResponse {
+ resp := channelConfigResponse{
+ ConfiguredSecrets: []string{},
+ ConfigKey: item.ConfigKey,
+ Variant: item.Variant,
+ }
+
+ bc := cfg.Channels.Get(item.ConfigKey)
+ if bc == nil {
+ bc = defaultChannelConfig(item.ConfigKey)
+ if bc == nil {
+ resp.Config = map[string]any{}
+ return resp
+ }
+ }
+
+ // Detect configured secrets by checking the raw Settings JSON
+ secrets := detectConfiguredSecrets(bc.Settings, item.Name)
+ resp.ConfiguredSecrets = secrets
+
+ // Parse settings into a generic map for JSON response
+ settings := map[string]any{}
+ if len(bc.Settings) > 0 {
+ if err := json.Unmarshal(bc.Settings, &settings); err != nil {
+ resp.Config = map[string]any{}
+ return resp
+ }
+ }
+
+ // Remove secure fields from response
+ for _, key := range secrets {
+ delete(settings, key)
+ }
+ addChannelCommonConfig(settings, bc)
+ resp.Config = settings
+
+ return resp
+}
+
+func defaultChannelConfig(configKey string) *config.Channel {
+ return config.DefaultConfig().Channels.Get(configKey)
+}
+
+func addChannelCommonConfig(settings map[string]any, bc *config.Channel) {
+ settings["enabled"] = bc.Enabled
+ if len(bc.AllowFrom) > 0 {
+ settings["allow_from"] = []string(bc.AllowFrom)
+ }
+ if bc.ReasoningChannelID != "" {
+ settings["reasoning_channel_id"] = bc.ReasoningChannelID
+ }
+ if bc.GroupTrigger.MentionOnly || len(bc.GroupTrigger.Prefixes) > 0 {
+ settings["group_trigger"] = bc.GroupTrigger
+ }
+ if bc.Typing.Enabled {
+ settings["typing"] = bc.Typing
+ }
+ if bc.Placeholder.Enabled || len(bc.Placeholder.Text) > 0 {
+ settings["placeholder"] = bc.Placeholder
+ }
+}
+
+func detectConfiguredSecrets(settings config.RawNode, channelName string) []string {
+ var m map[string]any
+ if err := json.Unmarshal(settings, &m); err != nil {
+ return nil
+ }
+
+ fields, ok := channelSecretFieldMap[channelName]
+ if !ok {
+ return nil
+ }
+
+ var found []string
+ for _, key := range fields {
+ if val, exists := m[key]; exists {
+ switch v := val.(type) {
+ case string:
+ if v != "" {
+ found = append(found, key)
+ }
+ case map[string]any:
+ if s, ok := v["s"].(string); ok && s != "" {
+ found = append(found, key)
+ }
+ }
+ }
+ }
+ if found == nil {
+ return []string{}
+ }
+ return found
+}
diff --git a/web/backend/api/channels_test.go b/web/backend/api/channels_test.go
new file mode 100644
index 000000000..0208af8e7
--- /dev/null
+++ b/web/backend/api/channels_test.go
@@ -0,0 +1,195 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestHandleGetChannelConfig_ReturnsSecretPresenceWithoutLeakingSecrets(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ bc := cfg.Channels[config.ChannelFeishu]
+ bc.Enabled = true
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ bcfg := decoded.(*config.FeishuSettings)
+ bcfg.AppID = "cli_test_app"
+ bcfg.AppSecret = *config.NewSecureString("feishu-secret-from-security")
+ bc.AllowFrom = config.FlexibleStringSlice{"ou_test_user"}
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/channels/feishu/config", nil)
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf(
+ "GET /api/channels/feishu/config status = %d, want %d, body=%s",
+ rec.Code,
+ http.StatusOK,
+ rec.Body.String(),
+ )
+ }
+ if strings.Contains(rec.Body.String(), "feishu-secret-from-security") {
+ t.Fatalf("response leaked secret value: %s", rec.Body.String())
+ }
+
+ var resp struct {
+ Config map[string]any `json:"config"`
+ ConfiguredSecrets []string `json:"configured_secrets"`
+ ConfigKey string `json:"config_key"`
+ Variant string `json:"variant"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("json.Unmarshal() error = %v", err)
+ }
+
+ if got := resp.ConfigKey; got != "feishu" {
+ t.Fatalf("config_key = %q, want %q", got, "feishu")
+ }
+ if got := resp.Config["app_id"]; got != "cli_test_app" {
+ t.Fatalf("config.app_id = %#v, want %q", got, "cli_test_app")
+ }
+ if got := resp.Config["enabled"]; got != true {
+ t.Fatalf("config.enabled = %#v, want true", got)
+ }
+ allowFrom, ok := resp.Config["allow_from"].([]any)
+ if !ok || len(allowFrom) != 1 || allowFrom[0] != "ou_test_user" {
+ t.Fatalf("config.allow_from = %#v, want [\"ou_test_user\"]", resp.Config["allow_from"])
+ }
+ if _, exists := resp.Config["app_secret"]; exists {
+ t.Fatalf("config should omit app_secret, got %#v", resp.Config["app_secret"])
+ }
+ if len(resp.ConfiguredSecrets) != 1 || resp.ConfiguredSecrets[0] != "app_secret" {
+ t.Fatalf("configured_secrets = %#v, want [\"app_secret\"]", resp.ConfiguredSecrets)
+ }
+}
+
+func TestHandleGetChannelConfig_ReturnsNotFoundForUnknownChannel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/channels/not-a-channel/config", nil)
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("GET /api/channels/not-a-channel/config status = %d, want %d", rec.Code, http.StatusNotFound)
+ }
+}
+
+func TestHandleGetChannelConfig_ReturnsCommonFieldsWhenSettingsEmpty(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ bc := cfg.Channels[config.ChannelFeishu]
+ bc.Enabled = true
+ bc.AllowFrom = config.FlexibleStringSlice{"ou_common_user"}
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/channels/feishu/config", nil)
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf(
+ "GET /api/channels/feishu/config status = %d, want %d, body=%s",
+ rec.Code,
+ http.StatusOK,
+ rec.Body.String(),
+ )
+ }
+
+ var resp struct {
+ Config map[string]any `json:"config"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("json.Unmarshal() error = %v", err)
+ }
+ if got := resp.Config["enabled"]; got != true {
+ t.Fatalf("config.enabled = %#v, want true", got)
+ }
+ allowFrom, ok := resp.Config["allow_from"].([]any)
+ if !ok || len(allowFrom) != 1 || allowFrom[0] != "ou_common_user" {
+ t.Fatalf("config.allow_from = %#v, want [\"ou_common_user\"]", resp.Config["allow_from"])
+ }
+}
+
+func TestHandleGetChannelConfig_ReturnsDefaultShapeForMissingChannel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ delete(cfg.Channels, config.ChannelIRC)
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/channels/irc/config", nil)
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf(
+ "GET /api/channels/irc/config status = %d, want %d, body=%s",
+ rec.Code,
+ http.StatusOK,
+ rec.Body.String(),
+ )
+ }
+
+ var resp struct {
+ Config map[string]any `json:"config"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("json.Unmarshal() error = %v", err)
+ }
+ if got := resp.Config["server"]; got != "" {
+ t.Fatalf("config.server = %#v, want empty string", got)
+ }
+ if got := resp.Config["nick"]; got != "picoclaw" {
+ t.Fatalf("config.nick = %#v, want %q", got, "picoclaw")
+ }
+ if got := resp.Config["enabled"]; got != false {
+ t.Fatalf("config.enabled = %#v, want false", got)
+ }
+}
diff --git a/web/backend/api/config.go b/web/backend/api/config.go
index 0add7594d..afcd3f74e 100644
--- a/web/backend/api/config.go
+++ b/web/backend/api/config.go
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
+ "reflect"
"regexp"
"strings"
@@ -20,6 +21,14 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns)
}
+func (h *Handler) applyRuntimeLogLevel() {
+ if h.debug {
+ logger.SetLevel(logger.DEBUG)
+ return
+ }
+ logger.SetLevelFromString(config.ResolveGatewayLogLevel(h.configPath))
+}
+
// handleGetConfig returns the complete system configuration.
//
// GET /api/config
@@ -47,8 +56,22 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
}
defer r.Body.Close()
+ var raw map[string]any
+ if err = json.Unmarshal(body, &raw); err != nil {
+ http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
+ return
+ }
+ if err = normalizeChannelArrayFields(raw); err != nil {
+ http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest)
+ return
+ }
+ normalizedBody, err := json.Marshal(raw)
+ if err != nil {
+ http.Error(w, "Failed to normalize config payload", http.StatusBadRequest)
+ return
+ }
var cfg config.Config
- if err = json.Unmarshal(body, &cfg); err != nil {
+ if err = json.Unmarshal(normalizedBody, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
@@ -63,6 +86,7 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
return
}
+ applyConfigSecretsFromMap(&cfg, raw)
if errs := validateConfig(&cfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
@@ -74,13 +98,14 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
return
}
- logger.Infof("configuration updated successfully")
-
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
+ h.applyRuntimeLogLevel()
+ logger.Infof("configuration updated successfully")
+
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
@@ -124,7 +149,6 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
-
existing, err := json.Marshal(cfg)
if err != nil {
http.Error(w, "Failed to serialize current config", http.StatusInternalServerError)
@@ -139,6 +163,10 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
// Recursively merge patch into base
mergeMap(base, patch)
+ if err = normalizeChannelArrayFields(base); err != nil {
+ http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest)
+ return
+ }
// Convert merged map back to Config struct
merged, err := json.Marshal(base)
@@ -159,6 +187,7 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
return
}
+ applyConfigSecretsFromMap(&newCfg, base)
if errs := validateConfig(&newCfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
@@ -175,6 +204,9 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
return
}
+ h.applyRuntimeLogLevel()
+ logger.Infof("configuration updated successfully")
+
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
@@ -259,26 +291,54 @@ func validateConfig(cfg *config.Config) []string {
}
// Pico channel: token required when enabled
- if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token.String() == "" {
- errs = append(errs, "channels.pico.token is required when pico channel is enabled")
+ {
+ bc := cfg.Channels.GetByType(config.ChannelPico)
+ if bc != nil && bc.Enabled {
+ if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
+ if c, ok := decoded.(*config.PicoSettings); ok && c.Token.String() == "" {
+ errs = append(errs, "channels.pico.token is required when pico channel is enabled")
+ }
+ }
+ }
}
// Telegram: token required when enabled
- if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token.String() == "" {
- errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
+ {
+ bc := cfg.Channels.GetByType(config.ChannelTelegram)
+ if bc != nil && bc.Enabled {
+ if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
+ if c, ok := decoded.(*config.TelegramSettings); ok && c.Token.String() == "" {
+ errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
+ }
+ }
+ }
}
// Discord: token required when enabled
- if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token.String() == "" {
- errs = append(errs, "channels.discord.token is required when discord channel is enabled")
+ {
+ bc := cfg.Channels.GetByType(config.ChannelDiscord)
+ if bc != nil && bc.Enabled {
+ if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
+ if c, ok := decoded.(*config.DiscordSettings); ok && c.Token.String() == "" {
+ errs = append(errs, "channels.discord.token is required when discord channel is enabled")
+ }
+ }
+ }
}
- if cfg.Channels.WeCom.Enabled {
- if cfg.Channels.WeCom.BotID == "" {
- errs = append(errs, "channels.wecom.bot_id is required when wecom channel is enabled")
- }
- if cfg.Channels.WeCom.Secret.String() == "" {
- errs = append(errs, "channels.wecom.secret is required when wecom channel is enabled")
+ {
+ bc := cfg.Channels.GetByType(config.ChannelWeCom)
+ if bc != nil && bc.Enabled {
+ if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
+ if c, ok := decoded.(*config.WeComSettings); ok {
+ if c.BotID == "" {
+ errs = append(errs, "channels.wecom.bot_id is required when wecom channel is enabled")
+ }
+ if c.Secret.String() == "" {
+ errs = append(errs, "channels.wecom.secret is required when wecom channel is enabled")
+ }
+ }
+ }
}
}
@@ -325,3 +385,374 @@ func mergeMap(dst, src map[string]any) {
}
}
}
+
+func asMapField(value map[string]any, key string) (map[string]any, bool) {
+ raw, exists := value[key]
+ if !exists {
+ return nil, false
+ }
+ m, isMap := raw.(map[string]any)
+ return m, isMap
+}
+
+var (
+ allowFromHiddenCharsRe = regexp.MustCompile("[\u200B\u200C\u200D\u200E\u200F\u202A-\u202E\u2060-\u2069\uFEFF]")
+ allowFromSplitRe = regexp.MustCompile("[,\uFF0C、;;\r\n\t]+")
+ conservativeSplitRe = regexp.MustCompile("[,\uFF0C\r\n\t]+")
+)
+
+type stringArrayParserOptions struct {
+ stripHiddenChars bool
+}
+
+func normalizeChannelArrayFields(raw map[string]any) error {
+ channelsMap, hasChannels := asMapField(raw, "channel_list")
+ if !hasChannels {
+ return nil
+ }
+
+ defaultCfg := config.DefaultConfig()
+ for channelName, rawChannel := range channelsMap {
+ chMap, ok := rawChannel.(map[string]any)
+ if !ok {
+ continue
+ }
+
+ if rawAllowFrom, exists := chMap["allow_from"]; exists {
+ normalized, err := normalizeStringArrayValue(rawAllowFrom, stringArrayParserOptions{
+ stripHiddenChars: true,
+ })
+ if err != nil {
+ return fmt.Errorf("channel_list.%s.allow_from: %w", channelName, err)
+ }
+ chMap["allow_from"] = normalized
+ }
+
+ if groupTrigger, ok := asMapField(chMap, "group_trigger"); ok {
+ if rawPrefixes, exists := groupTrigger["prefixes"]; exists {
+ normalized, err := normalizeStringArrayValue(rawPrefixes, stringArrayParserOptions{})
+ if err != nil {
+ return fmt.Errorf("channel_list.%s.group_trigger.prefixes: %w", channelName, err)
+ }
+ groupTrigger["prefixes"] = normalized
+ }
+ }
+
+ settingsMap, hasSettings := asMapField(chMap, "settings")
+ if !hasSettings {
+ continue
+ }
+
+ settingsType := channelSettingsType(defaultCfg, channelName, chMap)
+ if settingsType == nil {
+ continue
+ }
+
+ for i := range settingsType.NumField() {
+ field := settingsType.Field(i)
+ if !field.IsExported() || !isStringSliceType(field.Type) {
+ continue
+ }
+ jsonKey := strings.Split(field.Tag.Get("json"), ",")[0]
+ if jsonKey == "" || jsonKey == "-" {
+ continue
+ }
+ rawValue, exists := settingsMap[jsonKey]
+ if !exists {
+ continue
+ }
+
+ options := stringArrayParserOptions{}
+ if jsonKey == "allow_from" {
+ options.stripHiddenChars = true
+ }
+ normalized, err := normalizeStringArrayValue(rawValue, options)
+ if err != nil {
+ return fmt.Errorf("channel_list.%s.settings.%s: %w", channelName, jsonKey, err)
+ }
+ settingsMap[jsonKey] = normalized
+ }
+ }
+ return nil
+}
+
+func channelSettingsType(
+ defaultCfg *config.Config,
+ channelName string,
+ channelMap map[string]any,
+) reflect.Type {
+ if channelType, _ := channelMap["type"].(string); channelType != "" {
+ if bc := defaultCfg.Channels.GetByType(channelType); bc != nil {
+ if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
+ return derefType(reflect.TypeOf(decoded))
+ }
+ }
+ }
+
+ if bc := defaultCfg.Channels.Get(channelName); bc != nil {
+ if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
+ return derefType(reflect.TypeOf(decoded))
+ }
+ }
+
+ return nil
+}
+
+func derefType(typ reflect.Type) reflect.Type {
+ for typ != nil && typ.Kind() == reflect.Ptr {
+ typ = typ.Elem()
+ }
+ return typ
+}
+
+func isStringSliceType(typ reflect.Type) bool {
+ typ = derefType(typ)
+ return typ != nil && typ.Kind() == reflect.Slice && typ.Elem().Kind() == reflect.String
+}
+
+func normalizeStringArrayValue(value any, options stringArrayParserOptions) ([]string, error) {
+ switch typed := value.(type) {
+ case nil:
+ return nil, nil
+ case string:
+ return parseStringArrayValue(typed, options), nil
+ case float64:
+ return normalizeStringArrayItems([]string{fmt.Sprintf("%.0f", typed)}, options), nil
+ case []string:
+ return normalizeStringArrayItems(typed, options), nil
+ case []any:
+ items := make([]string, 0, len(typed))
+ for _, item := range typed {
+ switch raw := item.(type) {
+ case string:
+ items = append(items, raw)
+ case float64:
+ items = append(items, fmt.Sprintf("%.0f", raw))
+ default:
+ return nil, fmt.Errorf("unsupported list item type %T", item)
+ }
+ }
+ return normalizeStringArrayItems(items, options), nil
+ default:
+ return nil, fmt.Errorf("unsupported list field type %T", value)
+ }
+}
+
+func parseStringArrayValue(raw string, options stringArrayParserOptions) []string {
+ if strings.TrimSpace(raw) == "" {
+ return []string{}
+ }
+ splitRe := conservativeSplitRe
+ if options.stripHiddenChars {
+ splitRe = allowFromSplitRe
+ }
+ return normalizeStringArrayItems(splitRe.Split(raw, -1), options)
+}
+
+func normalizeStringArrayItems(items []string, options stringArrayParserOptions) []string {
+ result := make([]string, 0, len(items))
+ seen := make(map[string]struct{}, len(items))
+ for _, item := range items {
+ normalized := item
+ if options.stripHiddenChars {
+ normalized = allowFromHiddenCharsRe.ReplaceAllString(normalized, "")
+ }
+ normalized = strings.TrimSpace(normalized)
+ if normalized == "" {
+ continue
+ }
+ if _, exists := seen[normalized]; exists {
+ continue
+ }
+ seen[normalized] = struct{}{}
+ result = append(result, normalized)
+ }
+ if len(result) == 0 {
+ return []string{}
+ }
+ return result
+}
+
+func getSecretString(m map[string]any, key string) (string, bool) {
+ if raw, exists := m[key]; exists {
+ s, isString := raw.(string)
+ if isString {
+ return s, true
+ }
+ }
+ if raw, exists := m["_"+key]; exists {
+ s, isString := raw.(string)
+ if isString {
+ return s, true
+ }
+ }
+ return "", false
+}
+
+func applyConfigSecretsFromMap(cfg *config.Config, raw map[string]any) {
+ channelsMap, hasChannels := asMapField(raw, "channel_list")
+ if !hasChannels {
+ return
+ }
+
+ for chName, chData := range channelsMap {
+ chMap, ok := chData.(map[string]any)
+ if !ok {
+ continue
+ }
+ bc := cfg.Channels.Get(chName)
+ if bc == nil {
+ continue
+ }
+ decoded, err := bc.GetDecoded()
+ if err != nil || decoded == nil {
+ continue
+ }
+ rv := reflect.ValueOf(decoded)
+ if rv.Kind() == reflect.Ptr {
+ rv = rv.Elem()
+ }
+ if rv.Kind() != reflect.Struct {
+ continue
+ }
+ // Channel-specific settings live under the "settings" key in the raw map
+ settingsMap := chMap
+ if sm, hasSettings := asMapField(chMap, "settings"); hasSettings {
+ settingsMap = sm
+ }
+ applySecureStringsToStruct(rv, settingsMap)
+ }
+
+ // Handle tools secrets
+ tools, hasTools := asMapField(raw, "tools")
+ if !hasTools {
+ return
+ }
+ skills, hasSkills := asMapField(tools, "skills")
+ if !hasSkills {
+ return
+ }
+ if github, hasGithub := asMapField(skills, "github"); hasGithub {
+ if token, hasToken := getSecretString(github, "token"); hasToken {
+ cfg.Tools.Skills.Github.Token.Set(token)
+ }
+ }
+ if registries, hasRegistries := asMapField(skills, "registries"); hasRegistries {
+ for registryName, rawRegistry := range registries {
+ registryMap, ok := rawRegistry.(map[string]any)
+ if !ok {
+ continue
+ }
+ if authToken, hasAuthToken := getSecretString(registryMap, "auth_token"); hasAuthToken {
+ registryCfg, _ := cfg.Tools.Skills.Registries.Get(registryName)
+ registryCfg.AuthToken.Set(authToken)
+ cfg.Tools.Skills.Registries.Set(registryName, registryCfg)
+ }
+ }
+ return
+ }
+
+ registriesList, hasRegistries := skills["registries"].([]any)
+ if !hasRegistries {
+ return
+ }
+ for _, rawRegistry := range registriesList {
+ registryMap, ok := rawRegistry.(map[string]any)
+ if !ok {
+ continue
+ }
+ name, _ := registryMap["name"].(string)
+ if name == "" {
+ continue
+ }
+ if authToken, hasAuthToken := getSecretString(registryMap, "auth_token"); hasAuthToken {
+ registryCfg, _ := cfg.Tools.Skills.Registries.Get(name)
+ registryCfg.AuthToken.Set(authToken)
+ cfg.Tools.Skills.Registries.Set(name, registryCfg)
+ }
+ }
+}
+
+// applySecureStringsToStruct walks a struct and applies SecureString fields
+// from the matching keys in rawMap. It recurses into nested maps and slices.
+func applySecureStringsToStruct(rv reflect.Value, rawMap map[string]any) {
+ rt := rv.Type()
+ for jsonKey, rawVal := range rawMap {
+ for i := range rt.NumField() {
+ f := rt.Field(i)
+ if !f.IsExported() {
+ continue
+ }
+ tag := f.Tag.Get("json")
+ name := strings.Split(tag, ",")[0]
+ if name != jsonKey {
+ continue
+ }
+ sf := rv.Field(i)
+ if !sf.CanSet() {
+ continue
+ }
+ // Direct SecureString field
+ if s, ok := rawVal.(string); ok {
+ if f.Type == reflect.TypeOf(config.SecureString{}) {
+ sf.Set(reflect.ValueOf(*config.NewSecureString(s)))
+ } else if f.Type == reflect.TypeOf(&config.SecureString{}) {
+ sf.Set(reflect.ValueOf(config.NewSecureString(s)))
+ }
+ continue
+ }
+ // Recurse into nested struct
+ if sf.Kind() == reflect.Struct {
+ if nested, ok := rawVal.(map[string]any); ok {
+ applySecureStringsToStruct(sf, nested)
+ }
+ continue
+ }
+ // Recurse into map fields (e.g., map[string]SomeStruct)
+ if sf.Kind() == reflect.Map && sf.Type().Elem().Kind() == reflect.Struct {
+ if nestedMap, ok := rawVal.(map[string]any); ok {
+ for mapKey, mapVal := range nestedMap {
+ nested, ok := mapVal.(map[string]any)
+ if !ok {
+ continue
+ }
+ elemType := sf.Type().Elem()
+ // Get existing element or create a new zero value
+ var elem reflect.Value
+ existing := sf.MapIndex(reflect.ValueOf(mapKey))
+ if existing.IsValid() {
+ if existing.Kind() == reflect.Interface {
+ existing = existing.Elem()
+ }
+ if existing.Kind() == reflect.Ptr && !existing.IsNil() {
+ elem = reflect.New(elemType)
+ elem.Elem().Set(existing.Elem())
+ } else if existing.Kind() == reflect.Struct {
+ elem = reflect.New(elemType)
+ elem.Elem().Set(existing)
+ }
+ }
+ if !elem.IsValid() {
+ elem = reflect.New(elemType)
+ }
+ applySecureStringsToStruct(elem.Elem(), nested)
+ sf.SetMapIndex(reflect.ValueOf(mapKey), elem.Elem())
+ }
+ }
+ continue
+ }
+ // Recurse into slice elements that are structs
+ if sf.Kind() == reflect.Slice && sf.Type().Elem().Kind() == reflect.Struct {
+ if sliceRaw, ok := rawVal.([]any); ok {
+ for idx, elemRaw := range sliceRaw {
+ if nested, ok := elemRaw.(map[string]any); ok {
+ if idx < sf.Len() {
+ applySecureStringsToStruct(sf.Index(idx), nested)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go
index 644284849..8377c2eca 100644
--- a/web/backend/api/config_test.go
+++ b/web/backend/api/config_test.go
@@ -6,11 +6,42 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
)
+func assertGatewayLogLevelApplied(t *testing.T, method, body string, want logger.LogLevel) {
+ t.Helper()
+
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ initialLevel := logger.GetLevel()
+ logger.SetLevel(logger.INFO)
+ t.Cleanup(func() {
+ logger.SetLevel(initialLevel)
+ })
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(method, "/api/config", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s /api/config status = %d, want %d, body=%s", method, rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if got := logger.GetLevel(); got != want {
+ t.Fatalf("logger.GetLevel() = %v, want %v", got, want)
+ }
+}
+
func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -20,7 +51,7 @@ func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testin
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
-"version": 1,
+"version": 3,
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace"
@@ -143,6 +174,409 @@ func TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *tes
}
}
+func TestHandlePatchConfig_SavesChannelListSettingsPatch(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "channel_list": {
+ "feishu": {
+ "enabled": true,
+ "allow_from": ["ou_patch_user"],
+ "settings": {
+ "app_id": "cli_patch_app",
+ "app_secret": "patch-secret",
+ "is_lark": true
+ }
+ }
+ }
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ bc := cfg.Channels[config.ChannelFeishu]
+ if !bc.Enabled {
+ t.Fatal("feishu should be enabled after PATCH")
+ }
+ if len(bc.AllowFrom) != 1 || bc.AllowFrom[0] != "ou_patch_user" {
+ t.Fatalf("feishu allow_from = %#v, want [\"ou_patch_user\"]", bc.AllowFrom)
+ }
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ feishuCfg := decoded.(*config.FeishuSettings)
+ if got := feishuCfg.AppID; got != "cli_patch_app" {
+ t.Fatalf("feishu app_id = %q, want %q", got, "cli_patch_app")
+ }
+ if got := feishuCfg.AppSecret.String(); got != "patch-secret" {
+ t.Fatalf("feishu app_secret = %q, want %q", got, "patch-secret")
+ }
+ if !feishuCfg.IsLark {
+ t.Fatal("feishu is_lark should be true after PATCH")
+ }
+}
+
+func TestHandlePatchConfig_NormalizesStringChannelArrayFields(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "channel_list": {
+ "pico": {
+ "type": "pico",
+ "allow_from": " ou_a\u200b,\u2060ou_b\tou_c\u202e,ou_a ",
+ "group_trigger": {
+ "prefixes": "/,!;\n?,/"
+ },
+ "settings": {
+ "allow_origins": "https://a.example.com,http://localhost:5173,https://a.example.com"
+ }
+ },
+ "irc": {
+ "type": "irc",
+ "settings": {
+ "channels": "#ops,\n#dev,\n#ops",
+ "request_caps": "multi-prefix,echo-message\tbatch,multi-prefix"
+ }
+ }
+ }
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+
+ picoChannel := cfg.Channels[config.ChannelPico]
+ if len(picoChannel.AllowFrom) != 3 ||
+ picoChannel.AllowFrom[0] != "ou_a" ||
+ picoChannel.AllowFrom[1] != "ou_b" ||
+ picoChannel.AllowFrom[2] != "ou_c" {
+ t.Fatalf("pico allow_from = %#v, want [\"ou_a\", \"ou_b\", \"ou_c\"]", picoChannel.AllowFrom)
+ }
+ if len(picoChannel.GroupTrigger.Prefixes) != 3 ||
+ picoChannel.GroupTrigger.Prefixes[0] != "/" ||
+ picoChannel.GroupTrigger.Prefixes[1] != "!;" ||
+ picoChannel.GroupTrigger.Prefixes[2] != "?" {
+ t.Fatalf(
+ "pico group_trigger.prefixes = %#v, want [\"/\", \"!;\", \"?\"]",
+ picoChannel.GroupTrigger.Prefixes,
+ )
+ }
+
+ decoded, err := picoChannel.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() pico error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ if len(picoCfg.AllowOrigins) != 2 ||
+ picoCfg.AllowOrigins[0] != "https://a.example.com" ||
+ picoCfg.AllowOrigins[1] != "http://localhost:5173" {
+ t.Fatalf(
+ "pico allow_origins = %#v, want [\"https://a.example.com\", \"http://localhost:5173\"]",
+ picoCfg.AllowOrigins,
+ )
+ }
+
+ ircChannel := cfg.Channels[config.ChannelIRC]
+ decoded, err = ircChannel.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() irc error = %v", err)
+ }
+ ircCfg := decoded.(*config.IRCSettings)
+ if len(ircCfg.Channels) != 2 ||
+ ircCfg.Channels[0] != "#ops" ||
+ ircCfg.Channels[1] != "#dev" {
+ t.Fatalf("irc channels = %#v, want [\"#ops\", \"#dev\"]", ircCfg.Channels)
+ }
+ if len(ircCfg.RequestCaps) != 3 ||
+ ircCfg.RequestCaps[0] != "multi-prefix" ||
+ ircCfg.RequestCaps[1] != "echo-message" ||
+ ircCfg.RequestCaps[2] != "batch" {
+ t.Fatalf(
+ "irc request_caps = %#v, want [\"multi-prefix\", \"echo-message\", \"batch\"]",
+ ircCfg.RequestCaps,
+ )
+ }
+}
+
+func TestHandlePatchConfig_NormalizesSingleNumericAllowFrom(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "channel_list": {
+ "telegram": {
+ "type": "telegram",
+ "allow_from": 123456
+ }
+ }
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ telegramChannel := cfg.Channels[config.ChannelTelegram]
+ if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "123456" {
+ t.Fatalf("telegram allow_from = %#v, want [\"123456\"]", telegramChannel.AllowFrom)
+ }
+}
+
+func TestHandlePatchConfig_RejectsInvalidChannelArrayFields(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ telegramChannel := cfg.Channels[config.ChannelTelegram]
+ telegramChannel.AllowFrom = config.FlexibleStringSlice{"existing-user"}
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ tests := []struct {
+ name string
+ body string
+ }{
+ {
+ name: "object allow_from",
+ body: `{
+ "channel_list": {
+ "telegram": {
+ "type": "telegram",
+ "allow_from": {"id": "bad"}
+ }
+ }
+ }`,
+ },
+ {
+ name: "boolean allow_from",
+ body: `{
+ "channel_list": {
+ "telegram": {
+ "type": "telegram",
+ "allow_from": true
+ }
+ }
+ }`,
+ },
+ {
+ name: "object settings array",
+ body: `{
+ "channel_list": {
+ "irc": {
+ "type": "irc",
+ "settings": {
+ "channels": {"name": "#ops"}
+ }
+ }
+ }
+ }`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(tt.body))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf(
+ "PATCH /api/config status = %d, want %d, body=%s",
+ rec.Code,
+ http.StatusBadRequest,
+ rec.Body.String(),
+ )
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ telegramChannel := cfg.Channels[config.ChannelTelegram]
+ if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "existing-user" {
+ t.Fatalf("telegram allow_from = %#v, want unchanged [\"existing-user\"]", telegramChannel.AllowFrom)
+ }
+ })
+ }
+}
+
+func TestHandlePatchConfig_ClearingAllowFromDoesNotLeaveEmptyStringItem(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ feishuChannel := cfg.Channels[config.ChannelFeishu]
+ feishuChannel.Enabled = true
+ feishuChannel.AllowFrom = config.FlexibleStringSlice{"ou_existing_user"}
+ decoded, err := feishuChannel.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ feishuCfg := decoded.(*config.FeishuSettings)
+ feishuCfg.AppID = "cli_existing_app"
+ feishuCfg.AppSecret = *config.NewSecureString("existing-secret")
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "channel_list": {
+ "feishu": {
+ "enabled": true,
+ "allow_from": "",
+ "settings": {
+ "app_id": "cli_existing_app"
+ }
+ }
+ }
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err = config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ feishuChannel = cfg.Channels[config.ChannelFeishu]
+ if len(feishuChannel.AllowFrom) != 0 {
+ t.Fatalf("feishu allow_from = %#v, want empty slice", feishuChannel.AllowFrom)
+ }
+
+ configData, err := os.ReadFile(configPath)
+ if err != nil {
+ t.Fatalf("ReadFile(configPath) error = %v", err)
+ }
+ if strings.Contains(string(configData), `"allow_from": [""]`) {
+ t.Fatalf("config file should not contain empty-string allow_from item: %s", string(configData))
+ }
+}
+
+func TestHandlePatchConfig_CreatesMissingChannelWithTypeAndSecret(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ delete(cfg.Channels, config.ChannelIRC)
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "channel_list": {
+ "irc": {
+ "enabled": true,
+ "type": "irc",
+ "settings": {
+ "server": "irc.example.com",
+ "password": "irc-patch-password"
+ }
+ }
+ }
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err = config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ bc := cfg.Channels[config.ChannelIRC]
+ if bc == nil {
+ t.Fatal("irc channel should exist after PATCH")
+ }
+ if got := bc.Type; got != config.ChannelIRC {
+ t.Fatalf("irc type = %q, want %q", got, config.ChannelIRC)
+ }
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ ircCfg := decoded.(*config.IRCSettings)
+ if got := ircCfg.Server; got != "irc.example.com" {
+ t.Fatalf("irc server = %q, want %q", got, "irc.example.com")
+ }
+ if got := ircCfg.Password.String(); got != "irc-patch-password" {
+ t.Fatalf("irc password = %q, want %q", got, "irc-patch-password")
+ }
+ configData, err := os.ReadFile(configPath)
+ if err != nil {
+ t.Fatalf("ReadFile(configPath) error = %v", err)
+ }
+ if bytes.Contains(configData, []byte("irc-patch-password")) {
+ t.Fatalf("config file leaked irc password: %s", string(configData))
+ }
+}
+
// setupPicoEnabledEnv creates a test environment with Pico channel enabled and
// its token stored only in .security.yml (not in the JSON payload).
func setupPicoEnabledEnv(t *testing.T) (string, func()) {
@@ -166,8 +600,14 @@ func setupPicoEnabledEnv(t *testing.T) (string, func()) {
APIKeys: config.SimpleSecureStrings("sk-default"),
}}
cfg.Agents.Defaults.ModelName = "custom-default"
- cfg.Channels.Pico.Enabled = true
- cfg.Channels.Pico.Token = *config.NewSecureString("test-pico-token")
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ bc.Enabled = true
+ picoCfg.Token = *config.NewSecureString("test-pico-token")
configPath := filepath.Join(tmp, "config.json")
if err := config.SaveConfig(configPath, cfg); err != nil {
@@ -251,6 +691,162 @@ func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) {
}
}
+func TestHandleUpdateConfig_AppliesGatewayLogLevel(t *testing.T) {
+ assertGatewayLogLevelApplied(t, http.MethodPut, `{
+ "version": 1,
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model_name": "custom-default"
+ }
+ },
+ "gateway": {
+ "log_level": "error"
+ },
+ "model_list": [
+ {
+ "model_name": "custom-default",
+ "model": "openai/gpt-4o",
+ "api_keys": ["sk-default"]
+ }
+ ]
+ }`, logger.ERROR)
+}
+
+func TestHandlePatchConfig_AppliesGatewayLogLevel(t *testing.T) {
+ assertGatewayLogLevelApplied(t, http.MethodPatch, `{
+ "gateway": {
+ "log_level": "debug"
+ }
+ }`, logger.DEBUG)
+}
+
+func TestHandlePatchConfig_PreservesDebugFlagOverride(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ initialLevel := logger.GetLevel()
+ logger.SetLevel(logger.INFO)
+ t.Cleanup(func() {
+ logger.SetLevel(initialLevel)
+ })
+
+ h := NewHandler(configPath)
+ h.SetDebug(true)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "gateway": {
+ "log_level": "error"
+ }
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if got := logger.GetLevel(); got != logger.DEBUG {
+ t.Fatalf("logger.GetLevel() = %v, want %v", got, logger.DEBUG)
+ }
+}
+
+func TestHandlePatchConfig_SavesDiscordTokenFromPayload(t *testing.T) {
+ t.Skip("TODO: fix this test")
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "channel_list": [
+ {
+ "name":"discord",
+ "enabled": true,
+ "token": "discord-test-token"
+ }
+ ]
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ bc := cfg.Channels[config.ChannelDiscord]
+ if !bc.Enabled {
+ t.Fatal("discord should be enabled after PATCH")
+ }
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ if got := decoded.(*config.DiscordSettings).Token.String(); got != "discord-test-token" {
+ t.Fatalf("discord token = %q, want %q", got, "discord-test-token")
+ }
+}
+
+func TestHandlePatchConfig_DoesNotPersistShadowRegistryAuthTokenField(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "tools": {
+ "skills": {
+ "registries": {
+ "github": {
+ "_auth_token": "ghp-shadow-token"
+ }
+ }
+ }
+ }
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
+ if !ok {
+ t.Fatal("github registry missing after PATCH")
+ }
+ if got := githubRegistry.AuthToken.String(); got != "ghp-shadow-token" {
+ t.Fatalf("github registry auth token = %q, want %q", got, "ghp-shadow-token")
+ }
+ if got := githubRegistry.BaseURL; got != "https://github.com" {
+ t.Fatalf("github registry base_url = %q, want %q", got, "https://github.com")
+ }
+
+ rawConfig, err := os.ReadFile(configPath)
+ if err != nil {
+ t.Fatalf("ReadFile(configPath) error = %v", err)
+ }
+ if strings.Contains(string(rawConfig), "_auth_token") {
+ t.Fatalf("config.json should not persist _auth_token shadow field, got:\n%s", string(rawConfig))
+ }
+}
+
func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -443,3 +1039,190 @@ func TestHandleTestCommandPatterns_InvalidJSON(t *testing.T) {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}
+
+func TestApplyConfigSecretsFromMap_TelegramToken(t *testing.T) {
+ cfg := config.DefaultConfig()
+ bc := cfg.Channels["telegram"]
+ bc.Enabled = true
+ // Pre-decode so extend is populated
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ tgCfg := decoded.(*config.TelegramSettings)
+ tgCfg.Token = *config.NewSecureString("original-token")
+
+ raw := map[string]any{
+ "channel_list": map[string]any{
+ "telegram": map[string]any{
+ "enabled": true,
+ "token": "secret-from-api",
+ },
+ },
+ }
+
+ applyConfigSecretsFromMap(cfg, raw)
+
+ if got := tgCfg.Token.String(); got != "secret-from-api" {
+ t.Fatalf("telegram token = %q, want %q", got, "secret-from-api")
+ }
+}
+
+func TestApplyConfigSecretsFromMap_TeamsWebhook(t *testing.T) {
+ // applyConfigSecretsFromMap recurses into nested maps to find
+ // SecureString fields at any depth (e.g. webhook_url inside webhooks map).
+ cfg := config.DefaultConfig()
+ bc := &config.Channel{Enabled: true, Type: config.ChannelTeamsWebHook}
+ cfg.Channels["teams_webhook"] = bc
+ target := &config.TeamsWebhookSettings{
+ Webhooks: map[string]config.TeamsWebhookTarget{
+ "default": {
+ WebhookURL: *config.NewSecureString("https://example.com/hook1"),
+ Title: "Default",
+ },
+ },
+ }
+ if err := bc.Decode(target); err != nil {
+ t.Fatalf("Decode() error = %v", err)
+ }
+
+ raw := map[string]any{
+ "channel_list": map[string]any{
+ "teams_webhook": map[string]any{
+ "enabled": true,
+ "settings": map[string]any{
+ "webhooks": map[string]any{
+ "default": map[string]any{
+ "webhook_url": "https://example.com/hook-updated",
+ "title": "Default Updated",
+ },
+ },
+ },
+ },
+ },
+ }
+
+ applyConfigSecretsFromMap(cfg, raw)
+
+ // Verify the decoded struct has the updated SecureString value
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ twCfg, ok := decoded.(*config.TeamsWebhookSettings)
+ if !ok {
+ t.Fatalf("expected *TeamsWebhookSettings, got %T", decoded)
+ }
+
+ hookURL := twCfg.Webhooks["default"].WebhookURL
+ if got := hookURL.String(); got != "https://example.com/hook-updated" {
+ t.Fatalf("webhook_url = %q, want %q", got, "https://example.com/hook-updated")
+ }
+ // Note: title is a plain string, not a SecureString, so it is NOT updated
+ // by applyConfigSecretsFromMap (only secure fields are handled).
+}
+
+func TestApplyConfigSecretsFromMap_MultipleChannels(t *testing.T) {
+ cfg := config.DefaultConfig()
+
+ // Setup telegram
+ bc := cfg.Channels["telegram"]
+ bc.Enabled = true
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() telegram error = %v", err)
+ }
+ tgCfg := decoded.(*config.TelegramSettings)
+ tgCfg.Token = *config.NewSecureString("old-telegram-token")
+
+ // Setup discord
+ bc = cfg.Channels["discord"]
+ bc.Enabled = true
+ decoded, err = bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() discord error = %v", err)
+ }
+ discCfg := decoded.(*config.DiscordSettings)
+ discCfg.Token = *config.NewSecureString("old-discord-token")
+
+ raw := map[string]any{
+ "channel_list": map[string]any{
+ "telegram": map[string]any{
+ "enabled": true,
+ "settings": map[string]any{
+ "token": "new-telegram-token",
+ },
+ },
+ "discord": map[string]any{
+ "enabled": true,
+ "settings": map[string]any{
+ "token": "new-discord-token",
+ },
+ },
+ },
+ }
+
+ applyConfigSecretsFromMap(cfg, raw)
+
+ if got := tgCfg.Token.String(); got != "new-telegram-token" {
+ t.Fatalf("telegram token = %q, want %q", got, "new-telegram-token")
+ }
+ if got := discCfg.Token.String(); got != "new-discord-token" {
+ t.Fatalf("discord token = %q, want %q", got, "new-discord-token")
+ }
+}
+
+func TestApplyConfigSecretsFromMap_SkipsNonStringValues(t *testing.T) {
+ cfg := config.DefaultConfig()
+ bc := cfg.Channels["telegram"]
+ bc.Enabled = true
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ tgCfg := decoded.(*config.TelegramSettings)
+ tgCfg.Token = *config.NewSecureString("original-token")
+
+ raw := map[string]any{
+ "channel_list": map[string]any{
+ "telegram": map[string]any{
+ "enabled": true,
+ "token": 12345, // not a string, should be skipped
+ },
+ },
+ }
+
+ applyConfigSecretsFromMap(cfg, raw)
+
+ if got := tgCfg.Token.String(); got != "original-token" {
+ t.Fatalf("telegram token = %q, want %q", got, "original-token")
+ }
+}
+
+func TestApplyConfigSecretsFromMap_ChannelNotDecodedYet(t *testing.T) {
+ cfg := config.DefaultConfig()
+ bc := cfg.Channels["telegram"]
+ bc.Enabled = true
+ // Don't decode — let the function handle lazy decoding
+ bc.Type = config.ChannelTelegram
+
+ raw := map[string]any{
+ "channel_list": map[string]any{
+ "telegram": map[string]any{
+ "enabled": true,
+ "token": "lazy-decoded-token",
+ },
+ },
+ }
+
+ applyConfigSecretsFromMap(cfg, raw)
+
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ tgCfg := decoded.(*config.TelegramSettings)
+ if got := tgCfg.Token.String(); got != "lazy-decoded-token" {
+ t.Fatalf("telegram token = %q, want %q", got, "lazy-decoded-token")
+ }
+}
diff --git a/web/backend/api/exec_nonwindows.go b/web/backend/api/exec_nonwindows.go
new file mode 100644
index 000000000..0dc3c0e94
--- /dev/null
+++ b/web/backend/api/exec_nonwindows.go
@@ -0,0 +1,11 @@
+//go:build !windows
+
+package api
+
+import "os/exec"
+
+func launcherExecCommand(name string, args ...string) *exec.Cmd {
+ return exec.Command(name, args...)
+}
+
+func applyLauncherProcAttrs(_ *exec.Cmd) {}
diff --git a/web/backend/api/exec_windows.go b/web/backend/api/exec_windows.go
new file mode 100644
index 000000000..86d3193a0
--- /dev/null
+++ b/web/backend/api/exec_windows.go
@@ -0,0 +1,24 @@
+//go:build windows
+
+package api
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+func launcherExecCommand(name string, args ...string) *exec.Cmd {
+ cmd := exec.Command(name, args...)
+ applyLauncherProcAttrs(cmd)
+ return cmd
+}
+
+func applyLauncherProcAttrs(cmd *exec.Cmd) {
+ if cmd == nil {
+ return
+ }
+ if cmd.SysProcAttr == nil {
+ cmd.SysProcAttr = &syscall.SysProcAttr{}
+ }
+ cmd.SysProcAttr.HideWindow = true
+}
diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go
index 2621b722b..45f7e6912 100644
--- a/web/backend/api/gateway.go
+++ b/web/backend/api/gateway.go
@@ -2,6 +2,7 @@ package api
import (
"bufio"
+ "bytes"
"encoding/json"
"errors"
"fmt"
@@ -10,7 +11,9 @@ import (
"net/http"
"os"
"os/exec"
+ "reflect"
"runtime"
+ "sort"
"strconv"
"strings"
"sync"
@@ -20,6 +23,8 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/netbind"
+ ppid "github.com/sipeed/picoclaw/pkg/pid"
"github.com/sipeed/picoclaw/web/backend/utils"
)
@@ -33,16 +38,72 @@ var gateway = struct {
runtimeStatus string
startupDeadline time.Time
logs *LogBuffer
+ pidData *ppid.PidFileData // pid file data read from picoclaw.pid.json
+ picoToken string // cached raw pico token for upstream gateway proxy injection
}{
runtimeStatus: "stopped",
logs: NewLogBuffer(200),
}
+// refreshPicoTokensLocked reads the pico token from config and caches it.
+// Caller must hold gateway.mu (or be sole writer).
+func refreshPicoTokensLocked(configPath string) {
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ return
+ }
+ var picoCfg config.PicoSettings
+ if bc := cfg.Channels.GetByType(config.ChannelPico); bc != nil {
+ decoded, err := bc.GetDecoded()
+ if err == nil && decoded != nil {
+ if p, ok := decoded.(*config.PicoSettings); ok {
+ picoCfg = *p
+ }
+ }
+ }
+ gateway.picoToken = picoCfg.Token.String()
+}
+
+// ensurePicoTokenCachedLocked lazily fills the in-memory pico token cache when
+// the launcher has already discovered a running gateway via pidData, but has
+// not yet refreshed the token into memory.
+func ensurePicoTokenCachedLocked(configPath string) {
+ if gateway.picoToken != "" {
+ return
+ }
+ refreshPicoTokensLocked(configPath)
+}
+
+func (h *Handler) gatewayCommandArgs() []string {
+ args := []string{"gateway", "-E"}
+ if h.debug {
+ args = append(args, "-d")
+ }
+ return args
+}
+
+const (
+ protocolKey = "Sec-Websocket-Protocol"
+ tokenPrefix = "token."
+)
+
+// picoGatewayProtocol returns the gateway-facing pico subprotocol that the
+// launcher should inject when proxying browser traffic upstream.
+func picoGatewayProtocol() string {
+ gateway.mu.Lock()
+ defer gateway.mu.Unlock()
+ if gateway.picoToken == "" {
+ return ""
+ }
+ return tokenPrefix + gateway.picoToken
+}
+
var (
gatewayStartupWindow = 15 * time.Second
gatewayRestartGracePeriod = 5 * time.Second
gatewayRestartForceKillWindow = 3 * time.Second
gatewayRestartPollInterval = 100 * time.Millisecond
+ gatewayExecCommand = exec.Command
)
var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) {
@@ -50,16 +111,31 @@ var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response,
return client.Get(url)
}
-// getGatewayHealth checks the gateway health endpoint and returns the status response
+var gatewayProcessMatcher = isLikelyGatewayProcess
+
+// getGatewayHealth checks the gateway health endpoint and returns the status response.
// Returns (*health.StatusResponse, statusCode, error). If error is not nil, the other values are not valid.
func (h *Handler) getGatewayHealth(cfg *config.Config, timeout time.Duration) (*health.StatusResponse, int, error) {
- port := 18790
- if cfg != nil && cfg.Gateway.Port != 0 {
- port = cfg.Gateway.Port
+ // Prefer port/host from pidData when available.
+ var port int
+ var host string
+ gateway.mu.Lock()
+ if d := gateway.pidData; d != nil && d.Port > 0 {
+ port = d.Port
+ host = gatewayProbeHost(d.Host)
+ }
+ gateway.mu.Unlock()
+ if port == 0 {
+ port = 18790
+ if cfg != nil && cfg.Gateway.Port != 0 {
+ port = cfg.Gateway.Port
+ }
+ }
+ if host == "" {
+ host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
}
- probeHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
- url := "http://" + net.JoinHostPort(probeHost, strconv.Itoa(port)) + "/health"
+ url := "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/health"
return getGatewayHealthByURL(url, timeout)
}
@@ -79,6 +155,150 @@ func getGatewayHealthByURL(url string, timeout time.Duration) (*health.StatusRes
return &healthResponse, resp.StatusCode, nil
}
+// isLikelyGatewayProcess returns whether PID appears to be a picoclaw gateway
+// process plus whether inspection was conclusive on this platform/environment.
+func isLikelyGatewayProcess(pid int) (bool, bool) {
+ if pid <= 0 {
+ return false, true
+ }
+
+ if runtime.GOOS == "windows" {
+ psCmd := fmt.Sprintf(
+ `$p=Get-CimInstance Win32_Process -Filter "ProcessId = %d"; if ($null -eq $p) { "" } else { $p.CommandLine }`,
+ pid,
+ )
+ out, err := launcherExecCommand("powershell", "-NoProfile", "-NonInteractive", "-Command", psCmd).Output()
+ if err == nil {
+ cmdline := strings.TrimSpace(string(out))
+ if cmdline != "" {
+ return looksLikeGatewayCommandLine(cmdline), true
+ }
+ }
+
+ // Fallback: determine only whether the process still exists.
+ out, err = launcherExecCommand("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH").Output()
+ if err != nil {
+ return false, false
+ }
+ line := strings.ToLower(strings.TrimSpace(string(out)))
+ if line == "" {
+ return false, true
+ }
+ // A CSV row means the process exists, but may have a custom executable
+ // name we cannot classify here.
+ if strings.HasPrefix(line, "\"") {
+ if strings.Contains(line, "\"picoclaw.exe\"") {
+ return true, true
+ }
+ return false, true
+ }
+ if strings.Contains(line, "no tasks are running") {
+ return false, true
+ }
+ return false, true
+ }
+
+ out, err := launcherExecCommand("ps", "-o", "command=", "-p", strconv.Itoa(pid)).Output()
+ if err != nil {
+ return false, false
+ }
+ cmdline := strings.ToLower(strings.TrimSpace(string(out)))
+ if cmdline == "" {
+ return false, true
+ }
+ return looksLikeGatewayCommandLine(cmdline), true
+}
+
+// looksLikeGatewayCommandLine checks whether a process command line likely
+// represents "picoclaw gateway ..." regardless of executable filename.
+func looksLikeGatewayCommandLine(cmdline string) bool {
+ fields := strings.Fields(strings.ToLower(strings.TrimSpace(cmdline)))
+ if len(fields) == 0 {
+ return false
+ }
+ for _, f := range fields {
+ token := strings.Trim(f, `"'`)
+ if token == "gateway" || strings.HasSuffix(token, "/gateway") || strings.HasSuffix(token, `\gateway`) {
+ return true
+ }
+ }
+ return false
+}
+
+func (h *Handler) getGatewayHealthForPidData(
+ pidData *ppid.PidFileData,
+ cfg *config.Config,
+ timeout time.Duration,
+) (*health.StatusResponse, int, error) {
+ if pidData == nil {
+ return nil, 0, errors.New("nil pid data")
+ }
+
+ port := pidData.Port
+ if port == 0 {
+ port = 18790
+ if cfg != nil && cfg.Gateway.Port != 0 {
+ port = cfg.Gateway.Port
+ }
+ }
+
+ host := gatewayProbeHost(strings.TrimSpace(pidData.Host))
+ if host == "" {
+ host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
+ }
+ if host == "" {
+ host = netbind.ResolveAdaptiveLoopbackHost()
+ }
+
+ url := "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/health"
+ return getGatewayHealthByURL(url, timeout)
+}
+
+func (h *Handler) validateGatewayPidData(
+ pidData *ppid.PidFileData,
+ cfg *config.Config,
+) (ok bool, decisive bool, reason string) {
+ if pidData == nil || pidData.PID <= 0 {
+ return false, true, "invalid pid data"
+ }
+
+ if gatewayProcess, inspected := gatewayProcessMatcher(pidData.PID); inspected {
+ if !gatewayProcess {
+ return false, true, "pid process command is not picoclaw gateway"
+ }
+ return true, true, ""
+ }
+
+ healthResp, statusCode, err := h.getGatewayHealthForPidData(pidData, cfg, 800*time.Millisecond)
+ if err != nil {
+ return false, false, fmt.Sprintf("health probe failed: %v", err)
+ }
+ if statusCode != http.StatusOK {
+ return false, false, fmt.Sprintf("health endpoint returned status %d", statusCode)
+ }
+ if healthResp.PID > 0 && healthResp.PID != pidData.PID {
+ return false, true, fmt.Sprintf("health pid mismatch: pidFile=%d, health=%d", pidData.PID, healthResp.PID)
+ }
+ return true, true, ""
+}
+
+func (h *Handler) sanitizeGatewayPidData(pidData *ppid.PidFileData, cfg *config.Config) *ppid.PidFileData {
+ if pidData == nil {
+ return nil
+ }
+
+ ok, decisive, reason := h.validateGatewayPidData(pidData, cfg)
+ if ok {
+ return pidData
+ }
+
+ logger.Warnf("ignore pid file for PID %d: %s", pidData.PID, reason)
+ if decisive && ppid.RemovePidFileIfPID(globalConfigDir(), pidData.PID) {
+ logger.Warnf("removed stale pid file for PID %d", pidData.PID)
+ }
+ return nil
+}
+
// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux.
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
@@ -92,30 +312,33 @@ func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
// TryAutoStartGateway checks whether gateway start preconditions are met and
// starts it when possible. Intended to be called by the backend at startup.
func (h *Handler) TryAutoStartGateway() {
- // Check if gateway is already running via health endpoint
- cfg, cfgErr := config.LoadConfig(h.configPath)
- if cfgErr == nil && cfg != nil {
- healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second)
- if err == nil && statusCode == http.StatusOK {
- // Gateway is already running, attach to the existing process
- pid := healthResp.Pid
- gateway.mu.Lock()
- defer gateway.mu.Unlock()
- ready, reason, err := h.gatewayStartReady()
- if err != nil {
- logger.ErrorC("gateway", fmt.Sprintf("Skip auto-starting gateway: %v", err))
- return
- }
- if !ready {
- logger.InfoC("gateway", fmt.Sprintf("Skip auto-starting gateway: %s", reason))
- return
- }
- _, err = h.startGatewayLocked("starting", pid)
- if err != nil {
- logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err))
- }
+ // Check PID file first to detect an already-running gateway.
+ pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil)
+ if pidData != nil {
+ gateway.mu.Lock()
+ ready, reason, err := h.gatewayStartReady()
+ if err != nil {
+ logger.ErrorC("gateway", fmt.Sprintf("Skip auto-starting gateway: %v", err))
+ gateway.mu.Unlock()
return
}
+ logger.Infof("ready: %v, reason: %s", ready, reason)
+ if !ready {
+ logger.InfoC("gateway", fmt.Sprintf("Skip auto-starting gateway: %s", reason))
+ gateway.mu.Unlock()
+ return
+ }
+ pid := pidData.PID
+ _, err = h.startGatewayLocked("starting", pid)
+ if err != nil {
+ logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err))
+ } else {
+ gateway.pidData = pidData
+ refreshPicoTokensLocked(h.configPath)
+ logger.InfoC("gateway", fmt.Sprintf("Attached to running gateway via PID file (PID: %d)", pid))
+ }
+ gateway.mu.Unlock()
+ return
}
gateway.mu.Lock()
@@ -159,6 +382,9 @@ func (h *Handler) gatewayStartReady() (bool, string, error) {
if modelCfg == nil {
return false, fmt.Sprintf("default model %q is invalid", modelName), nil
}
+ if !defaultModelAllowedForModelConfig(modelCfg) {
+ return false, fmt.Sprintf("default model %q is not usable for chat", modelName), nil
+ }
if !hasModelConfiguration(modelCfg) {
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
@@ -211,6 +437,10 @@ func computeConfigSignature(cfg *config.Config) string {
}
if cfg.Tools.Web.Enabled {
toolSignatures = append(toolSignatures, "web")
+ webConfig, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(cfg.Tools.Web)))
+ if err == nil {
+ parts = append(parts, "webcfg:"+string(webConfig))
+ }
}
if cfg.Tools.WebFetch.Enabled {
toolSignatures = append(toolSignatures, "web_fetch")
@@ -254,9 +484,175 @@ func computeConfigSignature(cfg *config.Config) string {
if len(toolSignatures) > 0 {
parts = append(parts, "tools:"+strings.Join(toolSignatures, ","))
}
+ channelSignatures := computeChannelSignatures(cfg.Channels)
+ if len(channelSignatures) > 0 {
+ parts = append(parts, "channels:"+strings.Join(channelSignatures, ","))
+ }
return strings.Join(parts, ";")
}
+func computeChannelSignatures(channels config.ChannelsConfig) []string {
+ if len(channels) == 0 {
+ return nil
+ }
+
+ keys := make([]string, 0, len(channels))
+ for name := range channels {
+ keys = append(keys, name)
+ }
+ sort.Strings(keys)
+
+ signatures := make([]string, 0, len(keys))
+ for _, name := range keys {
+ channel := channels[name]
+ if channel == nil {
+ signatures = append(signatures, name+":")
+ continue
+ }
+
+ payload := struct {
+ Enabled bool `json:"enabled"`
+ Type string `json:"type"`
+ AllowFrom config.FlexibleStringSlice `json:"allow_from,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id,omitempty"`
+ GroupTrigger config.GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing config.TypingConfig `json:"typing,omitempty"`
+ Placeholder config.PlaceholderConfig `json:"placeholder,omitempty"`
+ Settings json.RawMessage `json:"settings,omitempty"`
+ }{
+ Enabled: channel.Enabled,
+ Type: channel.Type,
+ AllowFrom: channel.AllowFrom,
+ ReasoningChannelID: channel.ReasoningChannelID,
+ GroupTrigger: channel.GroupTrigger,
+ Typing: channel.Typing,
+ Placeholder: channel.Placeholder,
+ Settings: normalizeChannelSettings(channel),
+ }
+
+ encoded, err := json.Marshal(payload)
+ if err != nil {
+ signatures = append(signatures, name+":")
+ continue
+ }
+ signatures = append(signatures, name+":"+string(encoded))
+ }
+
+ return signatures
+}
+
+func normalizeChannelSettings(channel *config.Channel) json.RawMessage {
+ if channel == nil {
+ return nil
+ }
+
+ decoded, err := channel.GetDecoded()
+ if err == nil && decoded != nil {
+ normalized, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(decoded)))
+ if err == nil {
+ return normalized
+ }
+ }
+
+ return normalizeRawJSON(channel.Settings)
+}
+
+func normalizeRawJSON(raw config.RawNode) json.RawMessage {
+ if len(raw) == 0 {
+ return nil
+ }
+
+ var value any
+ if err := json.Unmarshal(raw, &value); err != nil {
+ return bytes.TrimSpace(raw)
+ }
+
+ normalized, err := json.Marshal(value)
+ if err != nil {
+ return bytes.TrimSpace(raw)
+ }
+ return normalized
+}
+
+func canonicalizeSignatureValue(value reflect.Value) any {
+ if !value.IsValid() {
+ return nil
+ }
+
+ if value.CanInterface() {
+ switch typed := value.Interface().(type) {
+ case config.SecureString:
+ return typed.String()
+ case *config.SecureString:
+ if typed == nil {
+ return ""
+ }
+ return typed.String()
+ case config.SecureStrings:
+ return typed.Values()
+ case *config.SecureStrings:
+ if typed == nil {
+ return nil
+ }
+ return typed.Values()
+ }
+ }
+
+ switch value.Kind() {
+ case reflect.Interface, reflect.Pointer:
+ if value.IsNil() {
+ return nil
+ }
+ return canonicalizeSignatureValue(value.Elem())
+ case reflect.Struct:
+ result := make(map[string]any)
+ valueType := value.Type()
+ for i := 0; i < value.NumField(); i++ {
+ field := valueType.Field(i)
+ if field.PkgPath != "" {
+ continue
+ }
+ tag := field.Tag.Get("json")
+ name := field.Name
+ if tag != "" {
+ if comma := strings.Index(tag, ","); comma >= 0 {
+ tag = tag[:comma]
+ }
+ if tag == "-" {
+ continue
+ }
+ if tag != "" {
+ name = tag
+ }
+ }
+ result[name] = canonicalizeSignatureValue(value.Field(i))
+ }
+ return result
+ case reflect.Slice, reflect.Array:
+ length := value.Len()
+ result := make([]any, 0, length)
+ for i := 0; i < length; i++ {
+ result = append(result, canonicalizeSignatureValue(value.Index(i)))
+ }
+ return result
+ case reflect.Map:
+ if value.Type().Key().Kind() != reflect.String {
+ return value.Interface()
+ }
+ result := make(map[string]any, value.Len())
+ iter := value.MapRange()
+ for iter.Next() {
+ result[iter.Key().String()] = canonicalizeSignatureValue(iter.Value())
+ }
+ return result
+ default:
+ if value.CanInterface() {
+ return value.Interface()
+ }
+ return nil
+ }
+}
+
func gatewayRestartRequiredBySignature(bootSignature, currentSignature, gatewayStatus string) bool {
if gatewayStatus != "running" {
return false
@@ -283,7 +679,13 @@ func isCmdProcessAliveLocked(cmd *exec.Cmd) bool {
return true
}
- return cmd.Process.Signal(syscall.Signal(0)) == nil
+ err := cmd.Process.Signal(syscall.Signal(0))
+ if err == nil {
+ return true
+ }
+ var errno syscall.Errno
+ // EPERM means the process exists but cannot be signaled by this user.
+ return errors.As(err, &errno) && errno == syscall.EPERM
}
func setGatewayRuntimeStatusLocked(status string) {
@@ -327,6 +729,15 @@ func gatewayStatusWithoutHealthLocked() string {
return "error"
}
if gateway.runtimeStatus == "running" {
+ // For attached processes there is no waiter goroutine; degrade stale
+ // running state once the tracked process exits.
+ if !isCmdProcessAliveLocked(gateway.cmd) {
+ gateway.cmd = nil
+ gateway.owned = false
+ gateway.bootDefaultModel = ""
+ gateway.bootConfigSignature = ""
+ return "stopped"
+ }
return "running"
}
if gateway.runtimeStatus == "error" {
@@ -383,6 +794,11 @@ func stopGatewayLocked() (int, error) {
}
pid := gateway.cmd.Process.Pid
+ if !gateway.owned {
+ if isGateway, inspected := gatewayProcessMatcher(pid); inspected && !isGateway {
+ return pid, fmt.Errorf("refuse to stop non-gateway process (PID %d)", pid)
+ }
+ }
// Send SIGTERM for graceful shutdown (SIGKILL on Windows)
var sigErr error
@@ -400,6 +816,7 @@ func stopGatewayLocked() (int, error) {
gateway.cmd = nil
gateway.owned = false
gateway.bootDefaultModel = ""
+ gateway.pidData = nil
setGatewayRuntimeStatusLocked("stopped")
return pid, nil
@@ -452,6 +869,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
pid = existingPid
gateway.cmd = nil // Clear first to ensure clean state
if err = attachToGatewayProcessLocked(pid, cfg); err != nil {
+ logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to existing gateway (PID %d): %v", pid, err))
return 0, err
}
@@ -461,8 +879,10 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
// Start new process
// Locate the picoclaw executable
execPath := utils.FindPicoclawBinary()
+ logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath))
- cmd = exec.Command(execPath, "gateway", "-E")
+ cmd = gatewayExecCommand(execPath, h.gatewayCommandArgs()...)
+ applyLauncherProcAttrs(cmd)
cmd.Env = os.Environ()
// Forward the launcher's config path via the environment variable that
// GetConfigPath() already reads, so the gateway sub-process uses the same
@@ -470,8 +890,9 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
if h.configPath != "" {
cmd.Env = append(cmd.Env, config.EnvConfig+"="+h.configPath)
}
- if host := h.gatewayHostOverride(); host != "" {
- cmd.Env = append(cmd.Env, config.EnvGatewayHost+"="+host)
+ gatewayHostOverride := h.gatewayHostOverride()
+ if gatewayHostOverride != "" {
+ cmd.Env = append(cmd.Env, config.EnvGatewayHost+"="+gatewayHostOverride)
}
stdoutPipe, err := cmd.StdoutPipe()
@@ -488,10 +909,21 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
gateway.logs.Reset()
// Ensure Pico Channel is configured before starting gateway
- if _, err := h.EnsurePicoChannel(""); err != nil {
+ changed, err := h.EnsurePicoChannel()
+ if err != nil {
logger.ErrorC("gateway", fmt.Sprintf("Warning: failed to ensure pico channel: %v", err))
// Non-fatal: gateway can still start without pico channel
}
+ // Refresh cached pico token in case EnsurePicoChannel generated a new one.
+ // Already holding gateway.mu from caller.
+ if changed {
+ refreshPicoTokensLocked(h.configPath)
+ cfg, err = config.LoadConfig(h.configPath)
+ if err != nil {
+ return 0, fmt.Errorf("failed to reload config after ensuring pico channel: %w", err)
+ }
+ defaultModelName = strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
+ }
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("failed to start gateway: %w", err)
@@ -529,8 +961,9 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
gateway.mu.Unlock()
}()
- // Start a goroutine to probe health and update the runtime state once ready.
+ // Start a goroutine to probe pidFile and health, update runtime state once ready.
go func() {
+ healthConfirmed := false
for i := 0; i < 30; i++ { // try for up to 15 seconds
time.Sleep(500 * time.Millisecond)
gateway.mu.Lock()
@@ -539,19 +972,46 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
if !stillOurs {
return
}
+
+ // Poll for pidFile first — once available we have port/host/token.
+ if pd := ppid.ReadPidFileWithCheck(globalConfigDir()); pd != nil && pd.PID == pid {
+ gateway.mu.Lock()
+ if gateway.cmd == cmd {
+ gateway.pidData = pd
+ var picoCfg config.PicoSettings
+ if bc := cfg.Channels.GetByType(config.ChannelPico); bc != nil {
+ decoded, err := bc.GetDecoded()
+ if err == nil && decoded != nil {
+ if p, ok := decoded.(*config.PicoSettings); ok {
+ picoCfg = *p
+ }
+ }
+ }
+ gateway.picoToken = picoCfg.Token.String()
+ setGatewayRuntimeStatusLocked("running")
+ }
+ gateway.mu.Unlock()
+ logger.InfoC("gateway", fmt.Sprintf("Gateway pidFile detected (PID: %d, port: %d)", pd.PID, pd.Port))
+ return
+ }
+
+ // Fallback: probe health endpoint to confirm liveness.
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
continue
}
- healthResp, statusCode, err := h.getGatewayHealth(cfg, 1*time.Second)
- if err == nil && statusCode == http.StatusOK && healthResp.Pid == pid {
- // Verify the health endpoint returns the expected pid
+ _, statusCode, err := h.getGatewayHealth(cfg, 1*time.Second)
+ if err == nil && statusCode == http.StatusOK {
gateway.mu.Lock()
if gateway.cmd == cmd {
setGatewayRuntimeStatusLocked("running")
}
gateway.mu.Unlock()
- return
+ if !healthConfirmed {
+ healthConfirmed = true
+ logger.InfoC("gateway", "Gateway health endpoint reachable; waiting for pid file")
+ }
+ continue
}
}
}()
@@ -563,49 +1023,47 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
//
// POST /api/gateway/start
func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
- // Prevent duplicate starts by checking health endpoint
- cfg, cfgErr := config.LoadConfig(h.configPath)
- if cfgErr == nil && cfg != nil {
- healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second)
- if err == nil && statusCode == http.StatusOK {
- // Gateway is already running, attach to the existing process
- pid := healthResp.Pid
- gateway.mu.Lock()
- ready, reason, err := h.gatewayStartReady()
- if err != nil {
- gateway.mu.Unlock()
- http.Error(
- w,
- fmt.Sprintf("Failed to validate gateway start conditions: %v", err),
- http.StatusInternalServerError,
- )
- return
- }
- if !ready {
- gateway.mu.Unlock()
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusBadRequest)
- json.NewEncoder(w).Encode(map[string]any{
- "status": "precondition_failed",
- "message": reason,
- })
- return
- }
- _, err = h.startGatewayLocked("starting", pid)
+ // Check PID file first to detect an already-running gateway.
+ pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil)
+ if pidData != nil {
+ pid := pidData.PID
+ gateway.mu.Lock()
+ ready, reason, err := h.gatewayStartReady()
+ if err != nil {
+ gateway.mu.Unlock()
+ http.Error(
+ w,
+ fmt.Sprintf("Failed to validate gateway start conditions: %v", err),
+ http.StatusInternalServerError,
+ )
+ return
+ }
+ if !ready {
gateway.mu.Unlock()
- if err != nil {
- logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err))
- http.Error(w, fmt.Sprintf("Failed to attach to gateway: %v", err), http.StatusInternalServerError)
- return
- }
w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
+ w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
- "status": "ok",
- "pid": pid,
+ "status": "precondition_failed",
+ "message": reason,
})
return
}
+ _, err = h.startGatewayLocked("starting", pid)
+ if err != nil {
+ gateway.mu.Unlock()
+ logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err))
+ http.Error(w, fmt.Sprintf("Failed to attach to gateway: %v", err), http.StatusInternalServerError)
+ return
+ }
+ gateway.pidData = pidData
+ gateway.mu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]any{
+ "status": "ok",
+ "pid": pid,
+ })
+ return
}
gateway.mu.Lock()
@@ -692,9 +1150,22 @@ func (h *Handler) RestartGateway() (int, error) {
gateway.mu.Lock()
previousCmd := gateway.cmd
+ previousOwned := gateway.owned
setGatewayRuntimeStatusLocked("restarting")
gateway.mu.Unlock()
+ if previousCmd != nil && previousCmd.Process != nil && !previousOwned {
+ if isGateway, inspected := gatewayProcessMatcher(previousCmd.Process.Pid); inspected && !isGateway {
+ logger.Warnf("refuse restarting non-gateway process (PID: %d)", previousCmd.Process.Pid)
+ gateway.mu.Lock()
+ if gateway.cmd == previousCmd {
+ setGatewayRuntimeStatusLocked("running")
+ }
+ gateway.mu.Unlock()
+ return 0, fmt.Errorf("refuse to restart non-gateway process (PID %d)", previousCmd.Process.Pid)
+ }
+ }
+
if err = stopGatewayProcessForRestart(previousCmd); err != nil {
gateway.mu.Lock()
if gateway.cmd == previousCmd {
@@ -805,66 +1276,42 @@ func (h *Handler) gatewayStatusData() map[string]any {
}
}
- // Probe health endpoint to get pid and status
- healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second)
- if err != nil {
+ // Primary detection: read PID file and check if process is alive.
+ pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), cfg)
+ if pidData != nil {
gateway.mu.Lock()
- data["gateway_status"] = gatewayStatusWithoutHealthLocked()
- gateway.mu.Unlock()
- logger.ErrorC("gateway", fmt.Sprintf("Gateway health check failed: %v", err))
- } else {
- if statusCode != http.StatusOK {
- logger.WarnC("gateway", fmt.Sprintf("Gateway health status: %d", statusCode))
- gateway.mu.Lock()
- setGatewayRuntimeStatusLocked("error")
- gateway.mu.Unlock()
- data["gateway_status"] = "error"
- data["status_code"] = statusCode
- } else {
- gateway.mu.Lock()
- setGatewayRuntimeStatusLocked("running")
- if gateway.cmd == nil || gateway.cmd.Process == nil || gateway.cmd.Process.Pid != healthResp.Pid {
- oldPid := "none"
- if gateway.cmd != nil && gateway.cmd.Process != nil {
- oldPid = fmt.Sprintf("%d", gateway.cmd.Process.Pid)
- }
- logger.InfoC(
- "gateway",
- fmt.Sprintf(
- "Detected new gateway PID (old: %s, new: %d), attempting to attach",
- oldPid,
- healthResp.Pid,
- ),
- )
-
- if err := attachToGatewayProcessLocked(healthResp.Pid, cfg); err != nil {
- // Failed to find the process, treat as error
- setGatewayRuntimeStatusLocked("error")
- data["gateway_status"] = "error"
- data["pid"] = healthResp.Pid
- logger.ErrorC(
- "gateway",
- fmt.Sprintf("Failed to attach to new gateway process (PID: %d): %v", healthResp.Pid, err),
- )
- } else {
- // Successfully attached, update response data
- bootDefaultModel := gateway.bootDefaultModel
- if bootDefaultModel != "" {
- data["boot_default_model"] = bootDefaultModel
- }
- data["gateway_status"] = "running"
- data["pid"] = healthResp.Pid
- }
- }
-
- bootDefaultModel := gateway.bootDefaultModel
- if bootDefaultModel != "" {
- data["boot_default_model"] = bootDefaultModel
- }
- data["gateway_status"] = "running"
- data["pid"] = healthResp.Pid
- gateway.mu.Unlock()
+ gateway.pidData = pidData
+ if pidData.Version != "" {
+ data["gateway_version"] = pidData.Version
}
+ setGatewayRuntimeStatusLocked("running")
+
+ // Attach if we don't already track this PID.
+ if gateway.cmd == nil || gateway.cmd.Process == nil || gateway.cmd.Process.Pid != pidData.PID {
+ _ = attachToGatewayProcessLocked(pidData.PID, cfg)
+ }
+
+ bootDefaultModel := gateway.bootDefaultModel
+ if bootDefaultModel != "" {
+ data["boot_default_model"] = bootDefaultModel
+ }
+ data["gateway_status"] = "running"
+ data["pid"] = pidData.PID
+ gateway.mu.Unlock()
+ } else {
+ // Intentionally skip health probe here; the startup goroutine
+ // (startGatewayLocked) already handles liveness detection via
+ // pidFile polling and health fallback.
+ gateway.mu.Lock()
+ status := gatewayStatusWithoutHealthLocked()
+ data["gateway_status"] = status
+ // Keep last known pidData while gateway is still in a transient
+ // running state; otherwise websocket proxy may lose auth token
+ // during short pid-file races.
+ if status == "stopped" || status == "error" {
+ gateway.pidData = nil
+ }
+ gateway.mu.Unlock()
}
gatewayStatus, _ := data["gateway_status"].(string)
diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go
index 6190f0c7c..03af7a9d3 100644
--- a/web/backend/api/gateway_host.go
+++ b/web/backend/api/gateway_host.go
@@ -8,9 +8,15 @@ import (
"strings"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/netbind"
)
func (h *Handler) effectiveLauncherPublic() bool {
+ if h.serverHostExplicit {
+ // -host takes precedence over -public and launcher-config public setting.
+ return false
+ }
+
if h.serverPublicExplicit {
return h.serverPublic
}
@@ -24,8 +30,11 @@ func (h *Handler) effectiveLauncherPublic() bool {
}
func (h *Handler) gatewayHostOverride() string {
+ if h.serverHostExplicit {
+ return strings.TrimSpace(h.serverHostInput)
+ }
if h.effectiveLauncherPublic() {
- return "0.0.0.0"
+ return "*"
}
return ""
}
@@ -41,10 +50,11 @@ func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string {
}
func gatewayProbeHost(bindHost string) string {
- if bindHost == "" || bindHost == "0.0.0.0" {
- return "127.0.0.1"
+ plan, err := netbind.BuildPlan(bindHost, netbind.DefaultLoopback)
+ if err != nil || strings.TrimSpace(plan.ProbeHost) == "" {
+ return netbind.ResolveAdaptiveLoopbackHost()
}
- return bindHost
+ return plan.ProbeHost
}
func (h *Handler) gatewayProxyURL() *url.URL {
@@ -72,11 +82,25 @@ func requestHostName(r *http.Request) string {
if strings.TrimSpace(r.Host) != "" {
return r.Host
}
- return "127.0.0.1"
+ return netbind.ResolveAdaptiveLoopbackHost()
+}
+
+func forwardedProtoFirst(r *http.Request) string {
+ raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
+ if raw == "" {
+ raw = forwardedRFC7239Proto(r)
+ }
+ if raw == "" {
+ return ""
+ }
+ if i := strings.IndexByte(raw, ','); i >= 0 {
+ raw = strings.TrimSpace(raw[:i])
+ }
+ return strings.ToLower(raw)
}
func requestWSScheme(r *http.Request) string {
- if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
+ if forwarded := forwardedProtoFirst(r); forwarded != "" {
proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0]))
if proto == "https" || proto == "wss" {
return "wss"
@@ -95,7 +119,7 @@ func requestWSScheme(r *http.Request) string {
// requestHTTPScheme returns http or https for URLs that are not WebSockets (e.g. SSE).
func requestHTTPScheme(r *http.Request) string {
- if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
+ if forwarded := forwardedProtoFirst(r); forwarded != "" {
proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0]))
if proto == "https" || proto == "wss" {
return "https"
@@ -107,6 +131,7 @@ func requestHTTPScheme(r *http.Request) string {
if r.TLS != nil {
return "https"
}
+
return "http"
}
@@ -128,6 +153,14 @@ func forwardedHostFirst(r *http.Request) string {
// forwardedRFC7239Host parses host= from the first Forwarded header element (RFC 7239).
func forwardedRFC7239Host(r *http.Request) string {
+ return forwardedRFC7239Param(r, "host")
+}
+
+func forwardedRFC7239Proto(r *http.Request) string {
+ return forwardedRFC7239Param(r, "proto")
+}
+
+func forwardedRFC7239Param(r *http.Request, key string) string {
v := strings.TrimSpace(r.Header.Get("Forwarded"))
if v == "" {
return ""
@@ -136,7 +169,7 @@ func forwardedRFC7239Host(r *http.Request) string {
for _, part := range strings.Split(first, ";") {
part = strings.TrimSpace(part)
low := strings.ToLower(part)
- if !strings.HasPrefix(low, "host=") {
+ if !strings.HasPrefix(low, key+"=") {
continue
}
val := strings.TrimSpace(part[strings.IndexByte(part, '=')+1:])
@@ -167,13 +200,21 @@ func clientVisiblePort(r *http.Request, serverListenPort int) string {
if p := forwardedPortFirst(r); p != "" {
return p
}
+ if fwdHost := forwardedHostFirst(r); fwdHost != "" {
+ if _, port, err := net.SplitHostPort(fwdHost); err == nil && port != "" {
+ return port
+ }
+ }
if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" {
return port
}
+ if strings.TrimSpace(r.Host) == "" && forwardedHostFirst(r) == "" {
+ return strconv.Itoa(serverListenPort)
+ }
if requestHTTPScheme(r) == "https" {
return "443"
}
- return strconv.Itoa(serverListenPort)
+ return "80"
}
// joinClientVisibleHostPort builds host:port for absolute URLs returned to the browser.
@@ -190,13 +231,12 @@ func joinClientVisibleHostPort(r *http.Request, host string, serverListenPort in
func (h *Handler) picoWebUIAddr(r *http.Request) string {
wsPort := h.serverPort
if wsPort == 0 {
- wsPort = 18800 // default web server port
+ wsPort = 18800
}
if fwdHost := forwardedHostFirst(r); fwdHost != "" {
return joinClientVisibleHostPort(r, fwdHost, wsPort)
}
- host := requestHostName(r)
- return net.JoinHostPort(host, strconv.Itoa(wsPort))
+ return joinClientVisibleHostPort(r, requestHostName(r), wsPort)
}
func (h *Handler) buildWsURL(r *http.Request) string {
diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go
index 7150b6fee..54d1010d2 100644
--- a/web/backend/api/gateway_host_test.go
+++ b/web/backend/api/gateway_host_test.go
@@ -3,6 +3,7 @@ package api
import (
"crypto/tls"
"errors"
+ "net"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -10,6 +11,7 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/netbind"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
)
@@ -26,8 +28,8 @@ func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) {
h := NewHandler(configPath)
h.SetServerOptions(18800, true, true, nil)
- if got := h.gatewayHostOverride(); got != "0.0.0.0" {
- t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0")
+ if got := h.gatewayHostOverride(); got != "*" {
+ t.Fatalf("gatewayHostOverride() = %q, want %q", got, "*")
}
}
@@ -48,7 +50,7 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) {
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = 18790
- req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
+ req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil)
req.Host = "192.168.1.9:18800"
if got := h.buildWsURL(req); got != "ws://192.168.1.9:18800/pico/ws" {
@@ -64,8 +66,36 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) {
}
func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) {
- if got := gatewayProbeHost("0.0.0.0"); got != "127.0.0.1" {
- t.Fatalf("gatewayProbeHost() = %q, want %q", got, "127.0.0.1")
+ want := "127.0.0.1"
+ if got := gatewayProbeHost("0.0.0.0"); got != want {
+ t.Fatalf("gatewayProbeHost() = %q, want %q", got, want)
+ }
+}
+
+func TestGatewayProbeHostUsesPreferredLoopbackForEmptyBind(t *testing.T) {
+ want := netbind.ResolveAdaptiveLoopbackHost()
+ if got := gatewayProbeHost(""); got != want {
+ t.Fatalf("gatewayProbeHost(empty) = %q, want %q", got, want)
+ }
+}
+
+func TestGatewayProbeHostUsesPreferredLoopbackForLocalhostBind(t *testing.T) {
+ want := netbind.ResolveAdaptiveLoopbackHost()
+ if got := gatewayProbeHost("localhost"); got != want {
+ t.Fatalf("gatewayProbeHost(localhost) = %q, want %q", got, want)
+ }
+}
+
+func TestGatewayProbeHostUsesLoopbackForIPv6WildcardBind(t *testing.T) {
+ want := "::1"
+ if got := gatewayProbeHost("::"); got != want {
+ t.Fatalf("gatewayProbeHost(::) = %q, want %q", got, want)
+ }
+}
+
+func TestGatewayProbeHostUsesFirstConcreteHostForMultiHostBind(t *testing.T) {
+ if got := gatewayProbeHost("127.0.0.1,::1"); got != "127.0.0.1" {
+ t.Fatalf("gatewayProbeHost(multi) = %q, want %q", got, "127.0.0.1")
}
}
@@ -137,8 +167,9 @@ func TestGetGatewayHealthUsesProbeHostForPublicLauncher(t *testing.T) {
_ = statusCode
_ = err
- if requestedURL != "http://127.0.0.1:18791/health" {
- t.Fatalf("health url = %q, want %q", requestedURL, "http://127.0.0.1:18791/health")
+ want := "http://" + net.JoinHostPort(netbind.ResolveAdaptiveLoopbackHost(), "18791") + "/health"
+ if requestedURL != want {
+ t.Fatalf("health url = %q, want %q", requestedURL, want)
}
}
@@ -150,12 +181,12 @@ func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) {
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Port = 18790
- req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
+ req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil)
req.Host = "chat.example.com"
req.Header.Set("X-Forwarded-Proto", "https")
- if got := h.buildWsURL(req); got != "wss://chat.example.com:18800/pico/ws" {
- t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18800/pico/ws")
+ if got := h.buildWsURL(req); got != "wss://chat.example.com:443/pico/ws" {
+ t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:443/pico/ws")
}
}
@@ -167,12 +198,12 @@ func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) {
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Port = 18790
- req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil)
+ req := httptest.NewRequest("GET", "https://launcher.local/api/pico/info", nil)
req.Host = "secure.example.com"
req.TLS = &tls.ConnectionState{}
- if got := h.buildWsURL(req); got != "wss://secure.example.com:18800/pico/ws" {
- t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18800/pico/ws")
+ if got := h.buildWsURL(req); got != "wss://secure.example.com:443/pico/ws" {
+ t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:443/pico/ws")
}
}
@@ -193,7 +224,7 @@ func TestBuildPicoURLsPreferXForwardedHost(t *testing.T) {
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Port = 18790
- req := httptest.NewRequest("GET", "http://127.0.0.1:18800/api/pico/token", nil)
+ req := httptest.NewRequest("GET", "http://127.0.0.1:18800/api/pico/info", nil)
req.Host = "127.0.0.1:18800"
req.Header.Set("X-Forwarded-Host", "vscode-tunnel.example.com")
req.Header.Set("X-Forwarded-Proto", "https")
@@ -218,13 +249,30 @@ func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) {
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Port = 18790
- req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil)
+ req := httptest.NewRequest("GET", "https://launcher.local/api/pico/info", nil)
req.Host = "chat.example.com"
req.TLS = &tls.ConnectionState{}
req.Header.Set("X-Forwarded-Proto", "http")
- if got := h.buildWsURL(req); got != "ws://chat.example.com:18800/pico/ws" {
- t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws")
+ if got := h.buildWsURL(req); got != "ws://chat.example.com:80/pico/ws" {
+ t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:80/pico/ws")
+ }
+}
+
+func TestBuildWsURLDoesNotTrustOriginWhenProxyOmitsForwardedProto(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+
+ req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil)
+ req.Host = "fs-952210-xwj.picoclaw.lan.sipeed.com"
+ req.Header.Set("Origin", "https://fs-952210-xwj.picoclaw.lan.sipeed.com")
+
+ if got := h.buildWsURL(req); got != "ws://fs-952210-xwj.picoclaw.lan.sipeed.com:80/pico/ws" {
+ t.Fatalf(
+ "buildWsURL() = %q, want %q",
+ got,
+ "ws://fs-952210-xwj.picoclaw.lan.sipeed.com:80/pico/ws",
+ )
}
}
@@ -233,10 +281,50 @@ func TestBuildWsURLUsesRequestHostNotGatewayBindLoopback(t *testing.T) {
h := NewHandler(configPath)
h.SetServerOptions(18800, false, false, nil)
- req := httptest.NewRequest("GET", "http://localhost:18800/api/pico/token", nil)
+ req := httptest.NewRequest("GET", "http://localhost:18800/api/pico/info", nil)
req.Host = "localhost:18800"
if got := h.buildWsURL(req); got != "ws://localhost:18800/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://localhost:18800/pico/ws")
}
}
+
+func TestGatewayHostOverrideWithExplicitHostAndAlignedGatewayHost(t *testing.T) {
+ h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
+ h.SetServerOptions(18800, false, false, nil)
+ h.SetServerBindHost("0.0.0.0", true)
+
+ if got := h.gatewayHostOverride(); got != "0.0.0.0" {
+ t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0")
+ }
+}
+
+func TestGatewayHostOverrideWithExplicitHostAndLocalhostGatewayHost(t *testing.T) {
+ h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
+ h.SetServerOptions(18800, false, false, nil)
+ h.SetServerBindHost("::", true)
+
+ if got := h.gatewayHostOverride(); got != "::" {
+ t.Fatalf("gatewayHostOverride() = %q, want %q", got, "::")
+ }
+}
+
+func TestGatewayHostOverrideWithExplicitMultiHost(t *testing.T) {
+ h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
+ h.SetServerOptions(18800, false, false, nil)
+ h.SetServerBindHost("127.0.0.1,::1", true)
+
+ if got := h.gatewayHostOverride(); got != "127.0.0.1,::1" {
+ t.Fatalf("gatewayHostOverride() = %q, want %q", got, "127.0.0.1,::1")
+ }
+}
+
+func TestGatewayHostExplicitIgnoresPublicFlag(t *testing.T) {
+ h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
+ h.SetServerOptions(18800, true, true, nil)
+ h.SetServerBindHost("127.0.0.1", true)
+
+ if got := h.effectiveLauncherPublic(); got {
+ t.Fatalf("effectiveLauncherPublic() = %t, want false when explicit host is set", got)
+ }
+}
diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go
index 42f0ab66c..f383089a6 100644
--- a/web/backend/api/gateway_test.go
+++ b/web/backend/api/gateway_test.go
@@ -17,6 +17,7 @@ import (
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
+ ppid "github.com/sipeed/picoclaw/pkg/pid"
"github.com/sipeed/picoclaw/web/backend/utils"
)
@@ -37,6 +38,36 @@ func startLongRunningProcess(t *testing.T) *exec.Cmd {
return cmd
}
+func startGatewayLikeProcess(t *testing.T) *exec.Cmd {
+ t.Helper()
+
+ var cmd *exec.Cmd
+ if runtime.GOOS == "windows" {
+ t.Skip("gateway-like process commandline check is not deterministic on Windows tests")
+ }
+ cmd = exec.Command("sh", "-c", "sleep 30 # picoclaw gateway")
+
+ if err := cmd.Start(); err != nil {
+ t.Fatalf("Start() error = %v", err)
+ }
+
+ return cmd
+}
+
+func writeTestPidFile(t *testing.T, data ppid.PidFileData) string {
+ t.Helper()
+
+ path := filepath.Join(globalConfigDir(), ".picoclaw.pid")
+ raw, err := json.MarshalIndent(data, "", " ")
+ if err != nil {
+ t.Fatalf("marshal pid file: %v", err)
+ }
+ if err := os.WriteFile(path, raw, 0o600); err != nil {
+ t.Fatalf("write pid file: %v", err)
+ }
+ return path
+}
+
func mockGatewayHealthResponse(statusCode, pid int) *http.Response {
return &http.Response{
StatusCode: statusCode,
@@ -65,17 +96,24 @@ func resetGatewayTestState(t *testing.T) {
t.Helper()
originalHealthGet := gatewayHealthGet
+ originalProcessMatcher := gatewayProcessMatcher
+ originalExecCommand := gatewayExecCommand
originalRestartGracePeriod := gatewayRestartGracePeriod
originalRestartForceKillWindow := gatewayRestartForceKillWindow
originalRestartPollInterval := gatewayRestartPollInterval
+ t.Setenv("PICOCLAW_HOME", t.TempDir())
t.Cleanup(func() {
gatewayHealthGet = originalHealthGet
+ gatewayProcessMatcher = originalProcessMatcher
+ gatewayExecCommand = originalExecCommand
gatewayRestartGracePeriod = originalRestartGracePeriod
gatewayRestartForceKillWindow = originalRestartForceKillWindow
gatewayRestartPollInterval = originalRestartPollInterval
gateway.mu.Lock()
gateway.cmd = nil
+ gateway.pidData = nil
+ gateway.owned = false
gateway.bootDefaultModel = ""
gateway.bootConfigSignature = ""
setGatewayRuntimeStatusLocked("stopped")
@@ -83,6 +121,226 @@ func resetGatewayTestState(t *testing.T) {
})
}
+func TestPicoGatewayProtocol(t *testing.T) {
+ resetGatewayTestState(t)
+
+ gateway.mu.Lock()
+ gateway.picoToken = "ui-token"
+ gateway.mu.Unlock()
+
+ if got := picoGatewayProtocol(); got != tokenPrefix+"ui-token" {
+ t.Fatalf("picoGatewayProtocol() = %q, want %q", got, tokenPrefix+"ui-token")
+ }
+}
+
+type gatewayStartEnvSnapshot struct {
+ GatewayHost string `json:"gateway_host"`
+ GatewayHostSet bool `json:"gateway_host_set"`
+ ConfigPath string `json:"config_path"`
+}
+
+func TestGatewayStartHelperProcess(t *testing.T) {
+ var envPath string
+ for i, arg := range os.Args {
+ if arg == "--" && i+2 < len(os.Args) && os.Args[i+1] == "gateway-env-helper" {
+ envPath = os.Args[i+2]
+ break
+ }
+ }
+ if envPath == "" {
+ t.Skip("helper process")
+ }
+
+ host, ok := os.LookupEnv(config.EnvGatewayHost)
+ raw, err := json.Marshal(gatewayStartEnvSnapshot{
+ GatewayHost: host,
+ GatewayHostSet: ok,
+ ConfigPath: os.Getenv(config.EnvConfig),
+ })
+ if err != nil {
+ _, _ = io.WriteString(os.Stderr, err.Error())
+ os.Exit(2)
+ }
+ if err := os.WriteFile(envPath, raw, 0o600); err != nil {
+ _, _ = io.WriteString(os.Stderr, err.Error())
+ os.Exit(2)
+ }
+ os.Exit(0)
+}
+
+func unsetGatewayStartEnvForTest(t *testing.T, key string) {
+ t.Helper()
+
+ prev, hadPrev := os.LookupEnv(key)
+ if err := os.Unsetenv(key); err != nil {
+ t.Fatalf("Unsetenv(%q) error = %v", key, err)
+ }
+ t.Cleanup(func() {
+ if hadPrev {
+ _ = os.Setenv(key, prev)
+ return
+ }
+ _ = os.Unsetenv(key)
+ })
+}
+
+func newGatewayStartTestHandler(t *testing.T) *Handler {
+ t.Helper()
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ cfg := config.DefaultConfig()
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ h.SetServerOptions(18800, false, false, nil)
+ return h
+}
+
+func startGatewayAndCaptureEnv(t *testing.T, h *Handler) gatewayStartEnvSnapshot {
+ t.Helper()
+
+ unsetGatewayStartEnvForTest(t, config.EnvGatewayHost)
+
+ envPath := filepath.Join(t.TempDir(), "gateway-child-env.json")
+ gatewayExecCommand = func(_ string, _ ...string) *exec.Cmd {
+ return exec.Command(
+ os.Args[0],
+ "-test.run=TestGatewayStartHelperProcess",
+ "--",
+ "gateway-env-helper",
+ envPath,
+ )
+ }
+
+ pid, err := h.startGatewayLocked("starting", 0)
+ if err != nil {
+ t.Fatalf("startGatewayLocked() error = %v", err)
+ }
+ if pid <= 0 {
+ t.Fatalf("startGatewayLocked() pid = %d, want > 0", pid)
+ }
+
+ deadline := time.Now().Add(3 * time.Second)
+ for {
+ raw, err := os.ReadFile(envPath)
+ if err == nil {
+ var snapshot gatewayStartEnvSnapshot
+ err = json.Unmarshal(raw, &snapshot)
+ if err != nil {
+ t.Fatalf("Unmarshal(child env) error = %v", err)
+ }
+ return snapshot
+ }
+ if !os.IsNotExist(err) {
+ t.Fatalf("ReadFile(%q) error = %v", envPath, err)
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("timed out waiting for gateway child env snapshot %q", envPath)
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+}
+
+func TestStartGatewayLocked_ForwardsLauncherHostOverrideToGatewayEnv(t *testing.T) {
+ h := newGatewayStartTestHandler(t)
+ h.SetServerBindHost("127.0.0.1,::1", true)
+
+ snapshot := startGatewayAndCaptureEnv(t, h)
+ if !snapshot.GatewayHostSet {
+ t.Fatal("gateway host env was not set")
+ }
+ if snapshot.GatewayHost != "127.0.0.1,::1" {
+ t.Fatalf("gateway host env = %q, want %q", snapshot.GatewayHost, "127.0.0.1,::1")
+ }
+ if snapshot.ConfigPath != h.configPath {
+ t.Fatalf("config env = %q, want %q", snapshot.ConfigPath, h.configPath)
+ }
+}
+
+func TestStartGatewayLocked_ForwardsLauncherHostFromEnvironmentToGatewayEnv(t *testing.T) {
+ h := newGatewayStartTestHandler(t)
+ h.SetServerBindHost("::", true)
+
+ snapshot := startGatewayAndCaptureEnv(t, h)
+ if !snapshot.GatewayHostSet {
+ t.Fatal("gateway host env was not set")
+ }
+ if snapshot.GatewayHost != "::" {
+ t.Fatalf("gateway host env = %q, want %q", snapshot.GatewayHost, "::")
+ }
+}
+
+func TestStartGatewayLocked_ForwardsWildcardHostForPublicLauncher(t *testing.T) {
+ h := newGatewayStartTestHandler(t)
+ h.SetServerOptions(18800, true, true, nil)
+
+ snapshot := startGatewayAndCaptureEnv(t, h)
+ if !snapshot.GatewayHostSet {
+ t.Fatal("gateway host env was not set")
+ }
+ if snapshot.GatewayHost != "*" {
+ t.Fatalf("gateway host env = %q, want %q", snapshot.GatewayHost, "*")
+ }
+}
+
+func TestStartGatewayLocked_UsesReloadedConfigForBootSignature(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("sleep command differs on Windows")
+ }
+
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ cfg := config.DefaultConfig()
+ delete(cfg.Channels, "pico")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ h.SetServerOptions(18800, false, false, nil)
+ gatewayExecCommand = func(_ string, _ ...string) *exec.Cmd {
+ return exec.Command("sleep", "30")
+ }
+
+ originalSignature := computeConfigSignature(cfg)
+ pid, err := h.startGatewayLocked("starting", 0)
+ if err != nil {
+ t.Fatalf("startGatewayLocked() error = %v", err)
+ }
+ if pid <= 0 {
+ t.Fatalf("startGatewayLocked() pid = %d, want > 0", pid)
+ }
+
+ gateway.mu.Lock()
+ cmd := gateway.cmd
+ bootSignature := gateway.bootConfigSignature
+ gateway.mu.Unlock()
+ t.Cleanup(func() {
+ if cmd != nil && cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ if cmd != nil {
+ _ = cmd.Wait()
+ }
+ })
+
+ updatedCfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ expectedSignature := computeConfigSignature(updatedCfg)
+ if expectedSignature == originalSignature {
+ t.Fatal("expected EnsurePicoChannel() to change the config signature during gateway start")
+ }
+ if bootSignature != expectedSignature {
+ t.Fatalf("bootConfigSignature = %q, want %q", bootSignature, expectedSignature)
+ }
+}
+
func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
@@ -99,6 +357,143 @@ func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
}
}
+func TestGatewayStartReady_RejectsASROnlyDefaultModel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "elevenlabs-asr",
+ Provider: "elevenlabs",
+ Model: "scribe_v1",
+ APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
+ }}
+ cfg.Agents.Defaults.ModelName = "elevenlabs-asr"
+
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ ready, reason, err := h.gatewayStartReady()
+ if err != nil {
+ t.Fatalf("gatewayStartReady() error = %v", err)
+ }
+ if ready {
+ t.Fatal("gatewayStartReady() ready = true, want false")
+ }
+ if reason != `default model "elevenlabs-asr" is not usable for chat` {
+ t.Fatalf(
+ "gatewayStartReady() reason = %q, want %q",
+ reason,
+ `default model "elevenlabs-asr" is not usable for chat`,
+ )
+ }
+}
+
+func TestLooksLikeGatewayCommandLine(t *testing.T) {
+ cases := []struct {
+ name string
+ cmdline string
+ want bool
+ }{
+ {
+ name: "default picoclaw gateway",
+ cmdline: "/usr/local/bin/picoclaw gateway -E",
+ want: true,
+ },
+ {
+ name: "renamed binary with gateway subcommand",
+ cmdline: "/opt/bin/custom-claw gateway -E -d",
+ want: true,
+ },
+ {
+ name: "standalone gateway binary path",
+ cmdline: "/opt/bin/gateway -E",
+ want: true,
+ },
+ {
+ name: "non gateway process",
+ cmdline: "/bin/sleep 30",
+ want: false,
+ },
+ {
+ name: "gateway substring only",
+ cmdline: "/opt/bin/gatewayd --serve",
+ want: false,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := looksLikeGatewayCommandLine(tc.cmdline)
+ if got != tc.want {
+ t.Fatalf("looksLikeGatewayCommandLine(%q) = %v, want %v", tc.cmdline, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestValidateGatewayPidDataAcceptsHealthWhenMatcherInconclusive(t *testing.T) {
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+
+ const testPID = 34567
+ pidData := &ppid.PidFileData{
+ PID: testPID,
+ Host: "127.0.0.1",
+ Port: 18790,
+ }
+
+ gatewayProcessMatcher = func(int) (bool, bool) { return false, false }
+ gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
+ return mockGatewayHealthResponse(http.StatusOK, testPID), nil
+ }
+
+ ok, decisive, reason := h.validateGatewayPidData(pidData, nil)
+ if !ok {
+ t.Fatalf("validateGatewayPidData() ok = false, want true (reason=%q)", reason)
+ }
+ if !decisive {
+ t.Fatalf("validateGatewayPidData() decisive = false, want true")
+ }
+}
+
+func TestValidateGatewayPidDataRejectsHealthPidMismatchWhenMatcherInconclusive(t *testing.T) {
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+
+ pidData := &ppid.PidFileData{
+ PID: 34567,
+ Host: "127.0.0.1",
+ Port: 18790,
+ }
+
+ gatewayProcessMatcher = func(int) (bool, bool) { return false, false }
+ gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
+ return mockGatewayHealthResponse(http.StatusOK, 99999), nil
+ }
+
+ ok, decisive, reason := h.validateGatewayPidData(pidData, nil)
+ if ok {
+ t.Fatalf("validateGatewayPidData() ok = true, want false")
+ }
+ if !decisive {
+ t.Fatalf("validateGatewayPidData() decisive = false, want true")
+ }
+ if !strings.Contains(reason, "health pid mismatch") {
+ t.Fatalf("validateGatewayPidData() reason = %q, want contains %q", reason, "health pid mismatch")
+ }
+}
+
func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
@@ -165,6 +560,17 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
}
}
+func TestGatewayCommandArgsIncludesDebugFlagWhenEnabled(t *testing.T) {
+ h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
+ h.SetDebug(true)
+
+ args := h.gatewayCommandArgs()
+ want := []string{"gateway", "-E", "-d"}
+ if strings.Join(args, " ") != strings.Join(want, " ") {
+ t.Fatalf("gatewayCommandArgs() = %v, want %v", args, want)
+ }
+}
+
func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -430,7 +836,7 @@ func TestGatewayStatusKeepsRunningWhenHealthProbeFailsAfterRunning(t *testing.T)
}
}
-func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) {
+func TestGatewayStatusKeepsPidDataWhileTrackedProcessAliveWhenPidFileUnavailable(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
@@ -446,6 +852,173 @@ func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) {
_ = cmd.Wait()
})
+ gateway.mu.Lock()
+ gateway.cmd = cmd
+ gateway.pidData = &ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "existing-token",
+ }
+ setGatewayRuntimeStatusLocked("running")
+ gateway.mu.Unlock()
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ gateway.mu.Lock()
+ defer gateway.mu.Unlock()
+ if gateway.pidData == nil {
+ t.Fatal("gateway.pidData was cleared while runtime status remained running")
+ }
+}
+
+func TestGatewayStatusDowngradesRunningWhenTrackedProcessExitedAndPidFileMissing(t *testing.T) {
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ cmd := startLongRunningProcess(t)
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+
+ gateway.mu.Lock()
+ gateway.cmd = cmd
+ gateway.pidData = &ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "stale-token",
+ }
+ setGatewayRuntimeStatusLocked("running")
+ gateway.mu.Unlock()
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ var body map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+ if got := body["gateway_status"]; got != "stopped" {
+ t.Fatalf("gateway_status = %#v, want %q", got, "stopped")
+ }
+
+ gateway.mu.Lock()
+ defer gateway.mu.Unlock()
+ if gateway.pidData != nil {
+ t.Fatal("gateway.pidData should be cleared when tracked process has exited")
+ }
+}
+
+func TestGatewayStatusIgnoresAndRemovesPidFileForNonGatewayProcess(t *testing.T) {
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ cmd := startLongRunningProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+
+ pidPath := writeTestPidFile(t, ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "stale-token",
+ Host: "127.0.0.1",
+ Port: 18790,
+ })
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ var body map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+ if got := body["gateway_status"]; got != "stopped" {
+ t.Fatalf("gateway_status = %#v, want %q", got, "stopped")
+ }
+ if _, err := os.Stat(pidPath); !os.IsNotExist(err) {
+ t.Fatal("stale pid file should be removed for non-gateway process")
+ }
+}
+
+func TestGatewayStopRefusesNonGatewayAttachedProcess(t *testing.T) {
+ resetGatewayTestState(t)
+ if runtime.GOOS == "windows" {
+ t.Skip("commandline-based process type check is best-effort on Windows")
+ }
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ cmd := startLongRunningProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+
+ gateway.mu.Lock()
+ gateway.cmd = cmd
+ gateway.owned = false
+ setGatewayRuntimeStatusLocked("running")
+ gateway.mu.Unlock()
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/gateway/stop", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
+ }
+ if !isCmdProcessAliveLocked(cmd) {
+ t.Fatal("non-gateway process should not be terminated by /api/gateway/stop")
+ }
+}
+
+func TestGatewayStatusReportsRunningFromPidProbe(t *testing.T) {
+ resetGatewayTestState(t)
+ gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+
gateway.mu.Lock()
setGatewayRuntimeStatusLocked("stopped")
gateway.mu.Unlock()
@@ -454,6 +1027,13 @@ func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) {
return mockGatewayHealthResponse(http.StatusOK, cmd.Process.Pid), nil
}
+ writeTestPidFile(t, ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "test-token",
+ Host: "127.0.0.1",
+ Port: 18790,
+ })
+
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
@@ -470,9 +1050,6 @@ func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) {
if got := body["gateway_status"]; got != "running" {
t.Fatalf("gateway_status = %#v, want %q", got, "running")
}
- if got := body["pid"]; got != float64(cmd.Process.Pid) {
- t.Fatalf("pid = %#v, want %d", got, cmd.Process.Pid)
- }
if got := body["gateway_restart_required"]; got != false {
t.Fatalf("gateway_restart_required = %#v, want false", got)
}
@@ -480,6 +1057,7 @@ func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) {
func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
resetGatewayTestState(t)
+ gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
@@ -498,14 +1076,23 @@ func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
mux := http.NewServeMux()
h.RegisterRoutes(mux)
- process, err := os.FindProcess(os.Getpid())
- if err != nil {
- t.Fatalf("FindProcess() error = %v", err)
- }
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+ writeTestPidFile(t, ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "test-token",
+ Host: "127.0.0.1",
+ Port: 18790,
+ })
bootSignature := computeConfigSignature(cfg)
gateway.mu.Lock()
- gateway.cmd = &exec.Cmd{Process: process}
+ gateway.cmd = cmd
gateway.bootDefaultModel = cfg.ModelList[0].ModelName
gateway.bootConfigSignature = bootSignature
setGatewayRuntimeStatusLocked("running")
@@ -614,6 +1201,136 @@ func TestGatewayStatusRequiresRestartAfterToolChange(t *testing.T) {
}
}
+func TestGatewayStatusRequiresRestartAfterChannelChange(t *testing.T) {
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ cfg := config.DefaultConfig()
+ cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
+ cfg.ModelList[0].SetAPIKey("test-key")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ process, err := os.FindProcess(os.Getpid())
+ if err != nil {
+ t.Fatalf("FindProcess() error = %v", err)
+ }
+
+ bootSignature := computeConfigSignature(cfg)
+ gateway.mu.Lock()
+ gateway.cmd = &exec.Cmd{Process: process}
+ gateway.bootDefaultModel = cfg.ModelList[0].ModelName
+ gateway.bootConfigSignature = bootSignature
+ setGatewayRuntimeStatusLocked("running")
+ gateway.mu.Unlock()
+
+ updatedCfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ telegram := updatedCfg.Channels.Get("telegram")
+ if telegram == nil {
+ t.Fatalf("expected default telegram channel config")
+ }
+ telegram.Enabled = !telegram.Enabled
+ if err := config.SaveConfig(configPath, updatedCfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
+ return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil
+ }
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ var body map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+
+ if got := body["gateway_status"]; got != "running" {
+ t.Fatalf("gateway_status = %#v, want %q", got, "running")
+ }
+ if got := body["gateway_restart_required"]; got != true {
+ t.Fatalf("gateway_restart_required = %#v, want true", got)
+ }
+}
+
+func TestGatewayStatusRequiresRestartAfterWebSearchConfigChange(t *testing.T) {
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ cfg := config.DefaultConfig()
+ cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
+ cfg.ModelList[0].SetAPIKey("test-key")
+ cfg.Tools.Web.Enabled = true
+ cfg.Tools.Web.Provider = "sogou"
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ process, err := os.FindProcess(os.Getpid())
+ if err != nil {
+ t.Fatalf("FindProcess() error = %v", err)
+ }
+
+ bootSignature := computeConfigSignature(cfg)
+ gateway.mu.Lock()
+ gateway.cmd = &exec.Cmd{Process: process}
+ gateway.bootDefaultModel = cfg.ModelList[0].ModelName
+ gateway.bootConfigSignature = bootSignature
+ setGatewayRuntimeStatusLocked("running")
+ gateway.mu.Unlock()
+
+ updatedCfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ updatedCfg.Tools.Web.Provider = "duckduckgo"
+ if err := config.SaveConfig(configPath, updatedCfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
+ return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil
+ }
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ var body map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+
+ if got := body["gateway_status"]; got != "running" {
+ t.Fatalf("gateway_status = %#v, want %q", got, "running")
+ }
+ if got := body["gateway_restart_required"]; got != true {
+ t.Fatalf("gateway_restart_required = %#v, want true", got)
+ }
+}
+
func TestGatewayStatusNoRestartRequiredForNonSensitiveChanges(t *testing.T) {
resetGatewayTestState(t)
diff --git a/web/backend/api/launcher_config.go b/web/backend/api/launcher_config.go
index e149d5671..92911157c 100644
--- a/web/backend/api/launcher_config.go
+++ b/web/backend/api/launcher_config.go
@@ -61,11 +61,15 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
return
}
- cfg := launcherconfig.Config{
- Port: payload.Port,
- Public: payload.Public,
- AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...),
+ cfg, err := h.loadLauncherConfig()
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError)
+ return
}
+ cfg.Port = payload.Port
+ cfg.Public = payload.Public
+ cfg.AllowedCIDRs = append([]string(nil), payload.AllowedCIDRs...)
+ cfg.LegacyLauncherToken = ""
if err := launcherconfig.Validate(cfg); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
diff --git a/web/backend/api/launcher_config_test.go b/web/backend/api/launcher_config_test.go
index 0d6af823c..68ab1be42 100644
--- a/web/backend/api/launcher_config_test.go
+++ b/web/backend/api/launcher_config_test.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "os"
"path/filepath"
"strings"
"testing"
@@ -41,6 +42,14 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
func TestPutLauncherConfigPersists(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
+ path := launcherconfig.PathForAppConfig(configPath)
+ if err := os.WriteFile(
+ path,
+ []byte(`{"port":18800,"public":false,"dashboard_password_hash":"saved-hash","launcher_token":"legacy-token"}`),
+ 0o600,
+ ); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
h := NewHandler(configPath)
mux := http.NewServeMux()
@@ -50,7 +59,9 @@ func TestPutLauncherConfigPersists(t *testing.T) {
req := httptest.NewRequest(
http.MethodPut,
"/api/system/launcher-config",
- strings.NewReader(`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`),
+ strings.NewReader(
+ `{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`,
+ ),
)
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
@@ -59,7 +70,6 @@ func TestPutLauncherConfigPersists(t *testing.T) {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
- path := launcherconfig.PathForAppConfig(configPath)
cfg, err := launcherconfig.Load(path, launcherconfig.Default())
if err != nil {
t.Fatalf("launcherconfig.Load() error = %v", err)
@@ -67,6 +77,12 @@ func TestPutLauncherConfigPersists(t *testing.T) {
if cfg.Port != 18080 || !cfg.Public {
t.Fatalf("saved config = %+v, want port=18080 public=true", cfg)
}
+ if cfg.DashboardPasswordHash != "saved-hash" {
+ t.Fatalf("saved dashboard_password_hash = %q, want saved-hash", cfg.DashboardPasswordHash)
+ }
+ if cfg.LegacyLauncherToken != "" {
+ t.Fatalf("saved legacy launcher_token = %q, want empty", cfg.LegacyLauncherToken)
+ }
if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" {
t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs)
}
diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go
index aeef85119..302231d80 100644
--- a/web/backend/api/model_status.go
+++ b/web/backend/api/model_status.go
@@ -1,37 +1,107 @@
package api
import (
+ "context"
"encoding/json"
"fmt"
+ "hash/fnv"
"net"
"net/http"
"net/url"
+ "os/exec"
+ "strconv"
"strings"
+ "sync"
"time"
+ "golang.org/x/sync/singleflight"
+
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
)
-const modelProbeTimeout = 800 * time.Millisecond
+const (
+ modelProbeTimeout = 800 * time.Millisecond
+ modelProbeSuccessBaseInterval = 2 * time.Second
+ modelProbeSuccessMaxInterval = 60 * time.Second
+ modelProbeFailureBaseInterval = 1 * time.Second
+ modelProbeFailureMaxInterval = 30 * time.Second
+ modelProbeBackoffMaxShift = 8
+ modelProbeCacheMaxEntries = 1024
+ modelProbeCacheEntryTTL = 30 * time.Minute
+ modelProbeCacheTrimToEntries = modelProbeCacheMaxEntries * 8 / 10
+ modelProbeTTLGCInterval = 1 * time.Minute
+)
+
+const (
+ modelStatusAvailable = "available"
+ modelStatusUnconfigured = "unconfigured"
+ modelStatusUnreachable = "unreachable"
+)
+
+type modelConfigurationSummary struct {
+ Available bool
+ Status string
+}
var (
probeTCPServiceFunc = probeTCPService
probeOllamaModelFunc = probeOllamaModel
probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel
+ probeCommandAvailableFunc = probeCommandAvailable
+ modelProbeNowFunc = time.Now
+ modelProbeState = newModelProbeCacheState()
)
+type modelProbeCacheState struct {
+ mu sync.RWMutex
+ cache map[string]*modelProbeCacheEntry
+ group singleflight.Group
+ nextTTLGCAt time.Time
+}
+
+type modelProbeCacheEntry struct {
+ lastResult bool
+ hasResult bool
+ successStreak int
+ failureStreak int
+ nextProbeAt time.Time
+ updatedAt time.Time
+}
+
+func newModelProbeCacheState() *modelProbeCacheState {
+ return &modelProbeCacheState{cache: map[string]*modelProbeCacheEntry{}}
+}
+
+func resetModelProbeCache() {
+ modelProbeState.resetForTest()
+}
+
+func (s *modelProbeCacheState) resetForTest() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.cache = map[string]*modelProbeCacheEntry{}
+ s.nextTTLGCAt = time.Time{}
+}
+
func hasModelConfiguration(m *config.ModelConfig) bool {
+ protocol := modelProtocol(m)
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
apiKey := strings.TrimSpace(m.APIKey())
if authMethod == "oauth" || authMethod == "token" {
- if provider, ok := oauthProviderForModel(m.Model); ok {
- cred, err := oauthGetCredential(provider)
- if err != nil || cred == nil {
- return false
- }
- return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != ""
+ if configured, checked := hasStoredOAuthCredential(m); checked {
+ return configured
}
+ }
+
+ if authMethod == "" && providerUsesImplicitOAuth(protocol) {
+ if configured, checked := hasStoredOAuthCredential(m); checked {
+ return configured
+ }
+ }
+
+ if providerUsesAmbientCredentials(protocol) {
return true
}
@@ -42,16 +112,51 @@ func hasModelConfiguration(m *config.ModelConfig) bool {
return apiKey != ""
}
-// isModelConfigured reports whether a model is currently available to use.
-// Local models must be reachable; remote/API-key models only need saved config.
-func isModelConfigured(m *config.ModelConfig) bool {
- if !hasModelConfiguration(m) {
+func hasStoredOAuthCredential(m *config.ModelConfig) (bool, bool) {
+ provider, ok := oauthProviderForModel(m)
+ if !ok {
+ return false, false
+ }
+ cred, err := oauthGetCredential(provider)
+ if err != nil || cred == nil {
+ return false, true
+ }
+ return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != "", true
+}
+
+func providerUsesImplicitOAuth(protocol string) bool {
+ switch protocol {
+ case "antigravity", "google-antigravity":
+ return true
+ default:
return false
}
- if requiresRuntimeProbe(m) {
- return probeLocalModelAvailability(m)
+}
+
+func providerUsesAmbientCredentials(protocol string) bool {
+ switch protocol {
+ case "bedrock":
+ // Bedrock relies on the AWS SDK credential chain instead of an explicit
+ // API key stored in ModelConfig. We cannot reliably preflight every AWS
+ // credential source here, so avoid misclassifying valid environments as
+ // "unconfigured" and defer concrete credential failures to runtime.
+ return true
+ default:
+ return false
}
- return true
+}
+
+func modelConfigurationStatus(m *config.ModelConfig) modelConfigurationSummary {
+ if !hasModelConfiguration(m) {
+ return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured}
+ }
+ if requiresRuntimeProbe(m) {
+ if probeLocalModelAvailability(m) {
+ return modelConfigurationSummary{Available: true, Status: modelStatusAvailable}
+ }
+ return modelConfigurationSummary{Available: false, Status: modelStatusUnreachable}
+ }
+ return modelConfigurationSummary{Available: true, Status: modelStatusAvailable}
}
func requiresRuntimeProbe(m *config.ModelConfig) bool {
@@ -60,10 +165,14 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool {
return true
}
- switch modelProtocol(m.Model) {
+ protocol := modelProtocol(m)
+
+ switch protocol {
case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot":
return true
- case "ollama", "vllm":
+ }
+
+ if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
apiBase := strings.TrimSpace(m.APIBase)
return apiBase == "" || hasLocalAPIBase(apiBase)
}
@@ -76,17 +185,47 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool {
}
func probeLocalModelAvailability(m *config.ModelConfig) bool {
+ cacheKey := modelProbeCacheKey(m)
+ return modelProbeState.probe(cacheKey, func() bool {
+ return runLocalModelProbe(m)
+ })
+}
+
+func (s *modelProbeCacheState) probe(cacheKey string, probeFunc func() bool) bool {
+ now := modelProbeNowFunc()
+ if cachedResult, ok := s.getCachedResult(cacheKey, now); ok {
+ return cachedResult
+ }
+
+ v, _, _ := s.group.Do(cacheKey, func() (any, error) {
+ now = modelProbeNowFunc()
+ if cachedResult, ok := s.getCachedResult(cacheKey, now); ok {
+ return cachedResult, nil
+ }
+
+ result := probeFunc()
+ s.setCachedResult(cacheKey, result, now)
+ return result, nil
+ })
+
+ result, _ := v.(bool)
+ return result
+}
+
+func runLocalModelProbe(m *config.ModelConfig) bool {
apiBase := modelProbeAPIBase(m)
- protocol, modelID := splitModel(m.Model)
+ protocol, modelID := splitModel(m)
switch protocol {
case "ollama":
return probeOllamaModelFunc(apiBase, modelID)
- case "vllm":
+ case "vllm", "lmstudio":
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
case "github-copilot", "copilot":
return probeTCPServiceFunc(apiBase)
- case "claude-cli", "claudecli", "codex-cli", "codexcli":
- return true
+ case "claude-cli", "claudecli":
+ return probeCommandAvailableFunc("claude")
+ case "codex-cli", "codexcli":
+ return probeCommandAvailableFunc("codex")
default:
if hasLocalAPIBase(apiBase) {
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
@@ -95,16 +234,211 @@ func probeLocalModelAvailability(m *config.ModelConfig) bool {
}
}
+func probeCommandAvailable(command string) bool {
+ _, err := exec.LookPath(command)
+ return err == nil
+}
+
+func modelProbeCacheKey(m *config.ModelConfig) string {
+ protocol, modelID := splitModel(m)
+
+ apiBaseRaw := modelProbeAPIBase(m)
+ apiBase := strings.ToLower(strings.TrimRight(strings.TrimSpace(apiBaseRaw), "/"))
+ apiKeyFingerprint := modelProbeAPIKeyFingerprint(m.APIKey())
+
+ var b strings.Builder
+ b.Grow(len(protocol) + len(modelID) + len(apiBase) + len(apiKeyFingerprint) + 8)
+ b.WriteString(protocol)
+ b.WriteByte('|')
+ b.WriteString(modelID)
+ b.WriteByte('|')
+ b.WriteString(apiBase)
+ b.WriteByte('|')
+ b.WriteString(apiKeyFingerprint)
+
+ return b.String()
+}
+
+func modelProbeAPIKeyFingerprint(raw string) string {
+ apiKey := strings.TrimSpace(raw)
+ if apiKey == "" {
+ return "none"
+ }
+
+ h := fnv.New64a()
+ _, _ = h.Write([]byte(apiKey))
+ return strconv.FormatUint(h.Sum64(), 36)
+}
+
+func (s *modelProbeCacheState) getCachedResult(cacheKey string, now time.Time) (bool, bool) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ entry, ok := s.cache[cacheKey]
+ if !ok || !entry.hasResult {
+ return false, false
+ }
+ if now.Before(entry.nextProbeAt) {
+ return entry.lastResult, true
+ }
+ return false, false
+}
+
+func (s *modelProbeCacheState) setCachedResult(cacheKey string, result bool, now time.Time) {
+ s.mu.Lock()
+
+ entry, ok := s.cache[cacheKey]
+ if !ok {
+ entry = &modelProbeCacheEntry{}
+ s.cache[cacheKey] = entry
+ }
+
+ entry.lastResult = result
+ entry.hasResult = true
+ entry.updatedAt = now
+
+ var delay time.Duration
+ if result {
+ entry.successStreak++
+ entry.failureStreak = 0
+ delay = modelProbeBackoffDelay(
+ modelProbeSuccessBaseInterval,
+ modelProbeSuccessMaxInterval,
+ entry.successStreak,
+ )
+ } else {
+ entry.failureStreak++
+ entry.successStreak = 0
+ delay = modelProbeBackoffDelay(
+ modelProbeFailureBaseInterval,
+ modelProbeFailureMaxInterval,
+ entry.failureStreak,
+ )
+ }
+
+ entry.nextProbeAt = now.Add(delay)
+
+ shouldRunTTLGC := modelProbeCacheEntryTTL > 0 && (s.nextTTLGCAt.IsZero() || !now.Before(s.nextTTLGCAt))
+ if shouldRunTTLGC {
+ s.nextTTLGCAt = now.Add(modelProbeTTLGCInterval)
+ }
+ shouldRunSizeGC := len(s.cache) > modelProbeCacheMaxEntries
+ s.mu.Unlock()
+
+ if shouldRunTTLGC || shouldRunSizeGC {
+ s.gc(now, shouldRunTTLGC)
+ }
+}
+
+func (s *modelProbeCacheState) gc(now time.Time, runTTL bool) {
+ type evictionCandidate struct {
+ key string
+ updatedAt time.Time
+ }
+
+ var expireBefore time.Time
+ if runTTL && modelProbeCacheEntryTTL > 0 {
+ expireBefore = now.Add(-modelProbeCacheEntryTTL)
+ }
+
+ s.mu.RLock()
+ cacheLen := len(s.cache)
+ if cacheLen == 0 {
+ s.mu.RUnlock()
+ return
+ }
+
+ expiredKeys := make([]string, 0)
+ if !expireBefore.IsZero() {
+ expiredKeys = make([]string, 0, min(cacheLen/8+1, 64))
+ for key, entry := range s.cache {
+ if entry.updatedAt.Before(expireBefore) {
+ expiredKeys = append(expiredKeys, key)
+ }
+ }
+ }
+
+ effectiveLen := cacheLen - len(expiredKeys)
+ removeCount := max(effectiveLen-modelProbeCacheTrimToEntries, 0)
+
+ candidates := make([]evictionCandidate, 0)
+ if removeCount > 0 {
+ candidates = make([]evictionCandidate, 0, effectiveLen)
+ for key, entry := range s.cache {
+ if !expireBefore.IsZero() && entry.updatedAt.Before(expireBefore) {
+ continue
+ }
+ candidates = append(candidates, evictionCandidate{key: key, updatedAt: entry.updatedAt})
+ }
+ }
+ s.mu.RUnlock()
+
+ if len(expiredKeys) == 0 && len(candidates) == 0 {
+ return
+ }
+
+ toEvict := map[string]time.Time{}
+ for i := 0; i < removeCount && len(candidates) > 0; i++ {
+ oldest := 0
+ for j := 1; j < len(candidates); j++ {
+ if candidates[j].updatedAt.Before(candidates[oldest].updatedAt) {
+ oldest = j
+ }
+ }
+ victim := candidates[oldest]
+ toEvict[victim.key] = victim.updatedAt
+ candidates[oldest] = candidates[len(candidates)-1]
+ candidates = candidates[:len(candidates)-1]
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if !expireBefore.IsZero() {
+ for _, key := range expiredKeys {
+ entry, ok := s.cache[key]
+ if ok && entry.updatedAt.Before(expireBefore) {
+ delete(s.cache, key)
+ }
+ }
+ }
+
+ for key, victimUpdatedAt := range toEvict {
+ entry, ok := s.cache[key]
+ if ok && !entry.updatedAt.After(victimUpdatedAt) {
+ delete(s.cache, key)
+ }
+ }
+}
+
+func modelProbeBackoffDelay(base, maxDelay time.Duration, streak int) time.Duration {
+ if streak <= 0 {
+ streak = 1
+ }
+
+ shift := min(streak-1, modelProbeBackoffMaxShift)
+
+ delay := base * time.Duration(1< 0 && (delay > maxDelay || delay < 0) {
+ return maxDelay
+ }
+ if delay <= 0 {
+ return base
+ }
+ return delay
+}
+
func modelProbeAPIBase(m *config.ModelConfig) string {
if apiBase := strings.TrimSpace(m.APIBase); apiBase != "" {
return normalizeModelProbeAPIBase(apiBase)
}
- switch modelProtocol(m.Model) {
- case "ollama":
- return "http://localhost:11434/v1"
- case "vllm":
- return "http://localhost:8000/v1"
+ protocol := modelProtocol(m)
+ if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
+ return providers.DefaultAPIBaseForProtocol(protocol)
+ }
+
+ switch protocol {
case "github-copilot", "copilot":
return "localhost:4321"
default:
@@ -134,8 +468,8 @@ func normalizeModelProbeAPIBase(raw string) string {
return u.String()
}
-func oauthProviderForModel(model string) (string, bool) {
- switch modelProtocol(model) {
+func oauthProviderForModel(m *config.ModelConfig) (string, bool) {
+ switch modelProtocol(m) {
case "openai":
return oauthProviderOpenAI, true
case "anthropic":
@@ -147,18 +481,14 @@ func oauthProviderForModel(model string) (string, bool) {
}
}
-func modelProtocol(model string) string {
- protocol, _ := splitModel(model)
+func modelProtocol(m *config.ModelConfig) string {
+ protocol, _ := splitModel(m)
return protocol
}
-func splitModel(model string) (protocol, modelID string) {
- model = strings.ToLower(strings.TrimSpace(model))
- protocol, _, found := strings.Cut(model, "/")
- if !found {
- return "openai", model
- }
- return protocol, strings.TrimSpace(model[strings.Index(model, "/")+1:])
+func splitModel(m *config.ModelConfig) (protocol, modelID string) {
+ protocol, modelID = providers.ExtractProtocol(m)
+ return strings.ToLower(strings.TrimSpace(protocol)), strings.ToLower(strings.TrimSpace(modelID))
}
func hasLocalAPIBase(raw string) bool {
@@ -189,7 +519,11 @@ func probeTCPService(raw string) bool {
return false
}
- conn, err := net.DialTimeout("tcp", hostPort, modelProbeTimeout)
+ ctx, cancel := context.WithTimeout(context.Background(), modelProbeTimeout)
+ defer cancel()
+
+ dialer := &net.Dialer{}
+ conn, err := dialer.DialContext(ctx, "tcp", hostPort)
if err != nil {
return false
}
@@ -244,7 +578,10 @@ func probeOpenAICompatibleModel(apiBase, modelID, apiKey string) bool {
}
func getJSON(rawURL string, out any, apiKey string) error {
- req, err := http.NewRequest(http.MethodGet, rawURL, nil)
+ ctx, cancel := context.WithTimeout(context.Background(), modelProbeTimeout)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return err
}
@@ -252,7 +589,7 @@ func getJSON(rawURL string, out any, apiKey string) error {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
- client := &http.Client{Timeout: modelProbeTimeout}
+ client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
@@ -318,10 +655,29 @@ func ollamaModelMatches(candidate, want string) bool {
if candidate == "" || want == "" {
return false
}
- if strings.EqualFold(candidate, want) {
- return true
+
+ candidateBase, candidateTag := splitOllamaModel(candidate)
+ wantBase, wantTag := splitOllamaModel(want)
+ if candidateBase == "" || wantBase == "" {
+ return false
}
- base, _, _ := strings.Cut(candidate, ":")
- return strings.EqualFold(base, want)
+ if candidateTag == "" {
+ candidateTag = "latest"
+ }
+ if wantTag == "" {
+ wantTag = "latest"
+ }
+
+ return strings.EqualFold(candidateBase, wantBase) && strings.EqualFold(candidateTag, wantTag)
+}
+
+func splitOllamaModel(raw string) (base, tag string) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", ""
+ }
+
+ base, tag, _ = strings.Cut(raw, ":")
+ return strings.TrimSpace(base), strings.TrimSpace(tag)
}
diff --git a/web/backend/api/model_status_test.go b/web/backend/api/model_status_test.go
index df942a9e9..d5463a856 100644
--- a/web/backend/api/model_status_test.go
+++ b/web/backend/api/model_status_test.go
@@ -3,7 +3,10 @@ package api
import (
"net/http"
"net/http/httptest"
+ "sync"
+ "sync/atomic"
"testing"
+ "time"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -35,3 +38,357 @@ func TestProbeLocalModelAvailability_OpenAICompatibleIncludesAPIKey(t *testing.T
t.Fatal("probeLocalModelAvailability() = false, want true when api_key is configured")
}
}
+
+func TestRequiresRuntimeProbe_LMStudio(t *testing.T) {
+ if !requiresRuntimeProbe(&config.ModelConfig{
+ Model: "lmstudio/openai/gpt-oss-20b",
+ }) {
+ t.Fatal("requiresRuntimeProbe(lmstudio with default base) = false, want true")
+ }
+
+ if requiresRuntimeProbe(&config.ModelConfig{
+ Model: "lmstudio/openai/gpt-oss-20b",
+ APIBase: "https://api.example.com/v1",
+ }) {
+ t.Fatal("requiresRuntimeProbe(lmstudio with remote base) = true, want false")
+ }
+}
+
+func TestModelProbeAPIBase_LMStudioDefault(t *testing.T) {
+ got := modelProbeAPIBase(&config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"})
+ if got != "http://localhost:1234/v1" {
+ t.Fatalf("modelProbeAPIBase(lmstudio) = %q, want %q", got, "http://localhost:1234/v1")
+ }
+}
+
+func TestProbeLocalModelAvailability_LMStudioUsesOpenAICompatibleProbe(t *testing.T) {
+ originalProbe := probeOpenAICompatibleModelFunc
+ defer func() { probeOpenAICompatibleModelFunc = originalProbe }()
+
+ called := false
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ called = true
+ if apiBase != "http://localhost:1234/v1" {
+ t.Fatalf("apiBase = %q, want %q", apiBase, "http://localhost:1234/v1")
+ }
+ if modelID != "openai/gpt-oss-20b" {
+ t.Fatalf("modelID = %q, want %q", modelID, "openai/gpt-oss-20b")
+ }
+ if apiKey != "" {
+ t.Fatalf("apiKey = %q, want empty", apiKey)
+ }
+ return true
+ }
+
+ model := &config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"}
+ if !probeLocalModelAvailability(model) {
+ t.Fatal("probeLocalModelAvailability(lmstudio) = false, want true")
+ }
+ if !called {
+ t.Fatal("probeOpenAICompatibleModelFunc was not called for lmstudio")
+ }
+}
+
+func TestModelProbeCacheKey_DifferentAPIKeysProduceDifferentKeys(t *testing.T) {
+ base := &config.ModelConfig{
+ ModelName: "local-vllm",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ AuthMethod: "local",
+ ConnectMode: "",
+ }
+
+ m1 := *base
+ m1.SetAPIKey("key-a")
+ m2 := *base
+ m2.SetAPIKey("key-b")
+
+ k1 := modelProbeCacheKey(&m1)
+ k2 := modelProbeCacheKey(&m2)
+ if k1 == k2 {
+ t.Fatal("modelProbeCacheKey() should differ when api key changes")
+ }
+}
+
+func TestModelProbeCacheKey_NormalizesTrailingSlashInAPIBase(t *testing.T) {
+ m1 := &config.ModelConfig{
+ ModelName: "local-vllm",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ }
+ m2 := &config.ModelConfig{
+ ModelName: "local-vllm",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1/",
+ }
+
+ k1 := modelProbeCacheKey(m1)
+ k2 := modelProbeCacheKey(m2)
+ if k1 != k2 {
+ t.Fatalf("modelProbeCacheKey() mismatch for equivalent api_base values: %q vs %q", k1, k2)
+ }
+}
+
+func TestModelProbeCacheKey_IgnoresDisplayAndConnectionFields(t *testing.T) {
+ base := &config.ModelConfig{
+ ModelName: "vllm-one",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ AuthMethod: "none",
+ ConnectMode: "http",
+ }
+ changed := &config.ModelConfig{
+ ModelName: "vllm-two",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ AuthMethod: "token",
+ ConnectMode: "ws",
+ }
+
+ k1 := modelProbeCacheKey(base)
+ k2 := modelProbeCacheKey(changed)
+ if k1 != k2 {
+ t.Fatalf("modelProbeCacheKey() should ignore non-probe fields, got %q vs %q", k1, k2)
+ }
+}
+
+func TestProbeLocalModelAvailability_SuccessBackoff(t *testing.T) {
+ resetModelProbeHooks(t)
+
+ now := time.Unix(1700000000, 0)
+ modelProbeNowFunc = func() time.Time { return now }
+
+ calls := 0
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ calls++
+ return true
+ }
+
+ model := &config.ModelConfig{
+ ModelName: "local-vllm",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ }
+
+ if !probeLocalModelAvailability(model) {
+ t.Fatal("first probe result = false, want true")
+ }
+ if calls != 1 {
+ t.Fatalf("probe calls after first probe = %d, want 1", calls)
+ }
+
+ if !probeLocalModelAvailability(model) {
+ t.Fatal("cached probe result = false, want true")
+ }
+ if calls != 1 {
+ t.Fatalf("probe calls after immediate re-check = %d, want 1", calls)
+ }
+
+ now = now.Add(modelProbeSuccessBaseInterval)
+ if !probeLocalModelAvailability(model) {
+ t.Fatal("second probe result = false, want true")
+ }
+ if calls != 2 {
+ t.Fatalf("probe calls after success backoff window = %d, want 2", calls)
+ }
+
+ now = now.Add(modelProbeSuccessBaseInterval)
+ if !probeLocalModelAvailability(model) {
+ t.Fatal("cached result after doubled backoff = false, want true")
+ }
+ if calls != 2 {
+ t.Fatalf("probe calls before doubled backoff expires = %d, want 2", calls)
+ }
+
+ now = now.Add(modelProbeSuccessBaseInterval)
+ if !probeLocalModelAvailability(model) {
+ t.Fatal("third probe result = false, want true")
+ }
+ if calls != 3 {
+ t.Fatalf("probe calls after doubled backoff expires = %d, want 3", calls)
+ }
+}
+
+func TestProbeLocalModelAvailability_FailureBackoff(t *testing.T) {
+ resetModelProbeHooks(t)
+
+ now := time.Unix(1700000100, 0)
+ modelProbeNowFunc = func() time.Time { return now }
+
+ calls := 0
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ calls++
+ return false
+ }
+
+ model := &config.ModelConfig{
+ ModelName: "local-vllm",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ }
+
+ if probeLocalModelAvailability(model) {
+ t.Fatal("first probe result = true, want false")
+ }
+ if calls != 1 {
+ t.Fatalf("probe calls after first failure = %d, want 1", calls)
+ }
+
+ if probeLocalModelAvailability(model) {
+ t.Fatal("cached failed probe result = true, want false")
+ }
+ if calls != 1 {
+ t.Fatalf("probe calls after immediate failed re-check = %d, want 1", calls)
+ }
+
+ now = now.Add(modelProbeFailureBaseInterval)
+ if probeLocalModelAvailability(model) {
+ t.Fatal("second failed probe result = true, want false")
+ }
+ if calls != 2 {
+ t.Fatalf("probe calls after failure backoff window = %d, want 2", calls)
+ }
+
+ now = now.Add(modelProbeFailureBaseInterval)
+ if probeLocalModelAvailability(model) {
+ t.Fatal("cached failure after doubled backoff = true, want false")
+ }
+ if calls != 2 {
+ t.Fatalf("probe calls before doubled failure backoff expires = %d, want 2", calls)
+ }
+
+ now = now.Add(modelProbeFailureBaseInterval)
+ if probeLocalModelAvailability(model) {
+ t.Fatal("third failed probe result = true, want false")
+ }
+ if calls != 3 {
+ t.Fatalf("probe calls after doubled failure backoff expires = %d, want 3", calls)
+ }
+}
+
+func TestProbeLocalModelAvailability_ResultFlipResetsBackoff(t *testing.T) {
+ resetModelProbeHooks(t)
+
+ now := time.Unix(1700000200, 0)
+ modelProbeNowFunc = func() time.Time { return now }
+
+ results := []bool{true, false, false}
+ index := 0
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ if index >= len(results) {
+ return false
+ }
+ result := results[index]
+ index++
+ return result
+ }
+
+ model := &config.ModelConfig{
+ ModelName: "local-vllm",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ }
+
+ if !probeLocalModelAvailability(model) {
+ t.Fatal("first probe result = false, want true")
+ }
+
+ now = now.Add(modelProbeSuccessBaseInterval)
+ if probeLocalModelAvailability(model) {
+ t.Fatal("second probe result = true, want false")
+ }
+
+ now = now.Add(modelProbeFailureBaseInterval)
+ if probeLocalModelAvailability(model) {
+ t.Fatal("third probe result = true, want false")
+ }
+
+ if index != 3 {
+ t.Fatalf("probe invocations = %d, want 3", index)
+ }
+}
+
+func TestProbeLocalModelAvailability_DeduplicatesInflightProbe(t *testing.T) {
+ resetModelProbeHooks(t)
+
+ now := time.Unix(1700000300, 0)
+ modelProbeNowFunc = func() time.Time { return now }
+
+ var calls int32
+ probeStarted := make(chan struct{})
+ releaseProbe := make(chan struct{})
+
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ if atomic.AddInt32(&calls, 1) == 1 {
+ close(probeStarted)
+ }
+ <-releaseProbe
+ return true
+ }
+
+ model := &config.ModelConfig{
+ ModelName: "local-vllm",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ }
+
+ const workers = 8
+ var wg sync.WaitGroup
+ results := make(chan bool, workers)
+ workerStarted := make(chan struct{}, workers)
+
+ for range workers {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ workerStarted <- struct{}{}
+ results <- probeLocalModelAvailability(model)
+ }()
+ }
+
+ for range workers {
+ <-workerStarted
+ }
+
+ select {
+ case <-probeStarted:
+ case <-time.After(200 * time.Millisecond):
+ t.Fatal("probe did not start in time")
+ }
+
+ if got := atomic.LoadInt32(&calls); got != 1 {
+ t.Fatalf("concurrent probe calls = %d, want 1", got)
+ }
+
+ close(releaseProbe)
+ wg.Wait()
+ close(results)
+
+ for result := range results {
+ if !result {
+ t.Fatal("deduplicated probe result = false, want true")
+ }
+ }
+
+ if got := atomic.LoadInt32(&calls); got != 1 {
+ t.Fatalf("final probe calls = %d, want 1", got)
+ }
+}
+
+func TestOllamaModelMatches_WithTagRequiresExactTag(t *testing.T) {
+ if ollamaModelMatches("llama3:8b", "llama3:7b") {
+ t.Fatal("ollamaModelMatches() = true, want false for mismatched tags")
+ }
+ if !ollamaModelMatches("llama3:7b", "llama3:7b") {
+ t.Fatal("ollamaModelMatches() = false, want true for exact tagged match")
+ }
+ if ollamaModelMatches("llama3:8b", "llama3") {
+ t.Fatal("ollamaModelMatches() = true, want false when request omits tag (defaults to latest)")
+ }
+ if !ollamaModelMatches("llama3:latest", "llama3") {
+ t.Fatal("ollamaModelMatches() = false, want true when request omits tag and candidate is latest")
+ }
+ if !ollamaModelMatches("llama3", "llama3") {
+ t.Fatal("ollamaModelMatches() = false, want true when both candidate and request omit tag (latest)")
+ }
+}
diff --git a/web/backend/api/models.go b/web/backend/api/models.go
index 38a55948b..8a66918f9 100644
--- a/web/backend/api/models.go
+++ b/web/backend/api/models.go
@@ -6,10 +6,13 @@ import (
"io"
"net/http"
"strconv"
+ "strings"
"sync"
+ "github.com/sipeed/picoclaw/pkg/audio/asr"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
)
// registerModelRoutes binds model list management endpoints to the ServeMux.
@@ -26,23 +29,201 @@ func (h *Handler) registerModelRoutes(mux *http.ServeMux) {
type modelResponse struct {
Index int `json:"index"`
ModelName string `json:"model_name"`
+ Provider string `json:"provider,omitempty"`
Model string `json:"model"`
APIBase string `json:"api_base,omitempty"`
APIKey string `json:"api_key"`
Proxy string `json:"proxy,omitempty"`
AuthMethod string `json:"auth_method,omitempty"`
// Advanced fields
- ConnectMode string `json:"connect_mode,omitempty"`
- Workspace string `json:"workspace,omitempty"`
- RPM int `json:"rpm,omitempty"`
- MaxTokensField string `json:"max_tokens_field,omitempty"`
- RequestTimeout int `json:"request_timeout,omitempty"`
- ThinkingLevel string `json:"thinking_level,omitempty"`
- ExtraBody map[string]any `json:"extra_body,omitempty"`
+ ConnectMode string `json:"connect_mode,omitempty"`
+ Workspace string `json:"workspace,omitempty"`
+ RPM int `json:"rpm,omitempty"`
+ MaxTokensField string `json:"max_tokens_field,omitempty"`
+ RequestTimeout int `json:"request_timeout,omitempty"`
+ ThinkingLevel string `json:"thinking_level,omitempty"`
+ ToolSchemaTransform string `json:"tool_schema_transform,omitempty"`
+ ExtraBody map[string]any `json:"extra_body,omitempty"`
+ CustomHeaders map[string]string `json:"custom_headers,omitempty"`
// Meta
- Configured bool `json:"configured"`
- IsDefault bool `json:"is_default"`
- IsVirtual bool `json:"is_virtual"`
+ Enabled bool `json:"enabled"`
+ Available bool `json:"available"`
+ Status string `json:"status"`
+ IsDefault bool `json:"is_default"`
+ IsVirtual bool `json:"is_virtual"`
+ DefaultModelAllowed bool `json:"default_model_allowed"`
+}
+
+func normalizeStoredModelConfig(mc *config.ModelConfig) bool {
+ if mc == nil {
+ return false
+ }
+
+ changed := false
+ model := strings.TrimSpace(mc.Model)
+ if model != mc.Model {
+ mc.Model = model
+ changed = true
+ }
+ provider := strings.TrimSpace(mc.Provider)
+ if provider != mc.Provider {
+ mc.Provider = provider
+ changed = true
+ }
+ authMethod := strings.ToLower(strings.TrimSpace(mc.AuthMethod))
+ if authMethod != mc.AuthMethod {
+ mc.AuthMethod = authMethod
+ changed = true
+ }
+
+ if provider != "" {
+ normalizedProvider := providers.NormalizeProvider(provider)
+ if providers.IsSupportedModelProvider(normalizedProvider) && normalizedProvider != provider {
+ mc.Provider = normalizedProvider
+ changed = true
+ }
+ if mc.Provider == "elevenlabs" {
+ if _, strippedModel, found := strings.Cut(
+ model,
+ "/",
+ ); found &&
+ providers.NormalizeProvider(strings.TrimSpace(provider)) == "elevenlabs" {
+ strippedModel = strings.TrimSpace(strippedModel)
+ if strippedModel != "" && strippedModel != mc.Model {
+ mc.Model = strippedModel
+ changed = true
+ }
+ }
+ if strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() {
+ mc.Model = asr.ElevenLabsSupportedModelID()
+ changed = true
+ }
+ }
+ return changed
+ }
+
+ effectiveProvider, modelID := providers.SplitModelProviderAndID(model, "openai")
+ if effectiveProvider == "" {
+ return changed
+ }
+ if mc.Provider != effectiveProvider {
+ mc.Provider = effectiveProvider
+ changed = true
+ }
+ if mc.Model != modelID {
+ mc.Model = modelID
+ changed = true
+ }
+ return changed
+}
+
+func normalizeIncomingModelConfig(mc *config.ModelConfig) {
+ if mc == nil {
+ return
+ }
+
+ mc.Model = strings.TrimSpace(mc.Model)
+ mc.Provider = strings.TrimSpace(mc.Provider)
+ mc.AuthMethod = strings.ToLower(strings.TrimSpace(mc.AuthMethod))
+ if mc.Provider == "" {
+ mc.Provider, mc.Model = providers.SplitModelProviderAndID(mc.Model, "openai")
+ } else {
+ mc.Provider = providers.NormalizeProvider(mc.Provider)
+ if mc.Provider == "elevenlabs" {
+ if _, strippedModel, found := strings.Cut(mc.Model, "/"); found {
+ strippedModel = strings.TrimSpace(strippedModel)
+ if strippedModel != "" {
+ mc.Model = strippedModel
+ }
+ }
+ }
+ }
+ if mc.Provider == "antigravity" && mc.AuthMethod == "" {
+ mc.AuthMethod = "oauth"
+ }
+}
+
+func createAllowedForProvider(provider string) bool {
+ normalized := providers.NormalizeProvider(provider)
+ switch normalized {
+ case "bedrock":
+ // Bedrock currently authenticates through the AWS SDK credential chain
+ // (env vars, shared profiles, IAM roles, etc.), and this Web layer does
+ // not yet have a reliable preflight check for those credential sources.
+ // Keep it creatable in the catalog and let provider construction/runtime
+ // return the concrete AWS error when the environment is incomplete.
+ return true
+ case "claude-cli", "codex-cli":
+ return cliProviderCreateAllowedFromCurrentStatus(normalized)
+ default:
+ return providers.IsCreatableModelProvider(normalized)
+ }
+}
+
+// cliProviderCreateAllowedFromCurrentStatus intentionally reuses the existing
+// local model status pipeline so provider catalog gating follows the same CLI
+// executable probe used by launcher readiness.
+func cliProviderCreateAllowedFromCurrentStatus(provider string) bool {
+ status := modelConfigurationStatus(&config.ModelConfig{
+ Provider: provider,
+ Model: provider,
+ })
+ return status.Available
+}
+
+func modelProviderOptionsForResponse() []providers.ModelProviderOption {
+ options := providers.ModelProviderOptions()
+ for i := range options {
+ options[i].CreateAllowed = createAllowedForProvider(options[i].ID)
+ }
+ return options
+}
+
+func defaultModelAllowedForModelConfig(mc *config.ModelConfig) bool {
+ provider, _ := providers.ExtractProtocol(mc)
+ return providers.IsDefaultModelProvider(provider)
+}
+
+func validateIncomingModelConfig(mc *config.ModelConfig, existing *config.ModelConfig) error {
+ if mc == nil {
+ return fmt.Errorf("model config is required")
+ }
+ if err := mc.Validate(); err != nil {
+ return err
+ }
+ if strings.TrimSpace(mc.Provider) == "" {
+ return fmt.Errorf("provider is required")
+ }
+ if !providers.IsSupportedModelProvider(mc.Provider) {
+ return fmt.Errorf("provider %q is not supported", mc.Provider)
+ }
+ if mc.Provider == "elevenlabs" && strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() {
+ return fmt.Errorf("provider %q only supports model %q", mc.Provider, asr.ElevenLabsSupportedModelID())
+ }
+ if !createAllowedForProvider(mc.Provider) {
+ if existing == nil {
+ return fmt.Errorf("provider %q is not available for new models", mc.Provider)
+ }
+ existingProvider, _ := providers.ExtractProtocol(existing)
+ if providers.NormalizeProvider(existingProvider) != mc.Provider {
+ return fmt.Errorf("provider %q is not available for selection", mc.Provider)
+ }
+ }
+ return nil
+}
+
+func normalizeStoredModelProviders(cfg *config.Config) bool {
+ if cfg == nil {
+ return false
+ }
+
+ changed := false
+ for _, model := range cfg.ModelList {
+ if normalizeStoredModelConfig(model) {
+ changed = true
+ }
+ }
+ return changed
}
// handleListModels returns all model_list entries with masked API keys.
@@ -55,47 +236,59 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
return
}
+ // Normalize legacy provider/model storage in memory so GET can round-trip
+ // through the current API shape without mutating the on-disk config.
+ normalizeStoredModelProviders(cfg)
+
defaultModel := cfg.Agents.Defaults.GetModelName()
- configured := make([]bool, len(cfg.ModelList))
+ modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList))
var wg sync.WaitGroup
wg.Add(len(cfg.ModelList))
for i, m := range cfg.ModelList {
go func(i int, m *config.ModelConfig) {
defer wg.Done()
- configured[i] = isModelConfigured(m)
+ modelStatuses[i] = modelConfigurationStatus(m)
}(i, m)
}
wg.Wait()
models := make([]modelResponse, 0, len(cfg.ModelList))
for i, m := range cfg.ModelList {
+ provider, modelID := providers.ExtractProtocol(m)
models = append(models, modelResponse{
- Index: i,
- ModelName: m.ModelName,
- Model: m.Model,
- APIBase: m.APIBase,
- APIKey: maskAPIKey(m.APIKey()),
- Proxy: m.Proxy,
- AuthMethod: m.AuthMethod,
- ConnectMode: m.ConnectMode,
- Workspace: m.Workspace,
- RPM: m.RPM,
- MaxTokensField: m.MaxTokensField,
- RequestTimeout: m.RequestTimeout,
- ThinkingLevel: m.ThinkingLevel,
- ExtraBody: m.ExtraBody,
- Configured: configured[i],
- IsDefault: m.ModelName == defaultModel,
- IsVirtual: m.IsVirtual(),
+ Index: i,
+ ModelName: m.ModelName,
+ Provider: provider,
+ Model: modelID,
+ APIBase: m.APIBase,
+ APIKey: maskAPIKey(m.APIKey()),
+ Proxy: m.Proxy,
+ AuthMethod: m.AuthMethod,
+ ConnectMode: m.ConnectMode,
+ Workspace: m.Workspace,
+ RPM: m.RPM,
+ MaxTokensField: m.MaxTokensField,
+ RequestTimeout: m.RequestTimeout,
+ ThinkingLevel: m.ThinkingLevel,
+ ToolSchemaTransform: m.ToolSchemaTransform,
+ ExtraBody: m.ExtraBody,
+ CustomHeaders: m.CustomHeaders,
+ Enabled: m.Enabled,
+ Available: modelStatuses[i].Available,
+ Status: modelStatuses[i].Status,
+ IsDefault: m.ModelName == defaultModel,
+ IsVirtual: m.IsVirtual(),
+ DefaultModelAllowed: defaultModelAllowedForModelConfig(m),
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
- "models": models,
- "total": len(models),
- "default_model": defaultModel,
+ "models": models,
+ "total": len(models),
+ "default_model": defaultModel,
+ "provider_options": modelProviderOptionsForResponse(),
})
}
@@ -121,7 +314,9 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
return
}
- if err = mc.Validate(); err != nil {
+ normalizeIncomingModelConfig(&mc.ModelConfig)
+
+ if err = validateIncomingModelConfig(&mc.ModelConfig, nil); err != nil {
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
return
}
@@ -137,6 +332,7 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
}
cfg.ModelList = append(cfg.ModelList, &mc.ModelConfig)
+ normalizeStoredModelProviders(cfg)
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
@@ -170,6 +366,12 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
}
defer r.Body.Close()
+ var rawFields map[string]json.RawMessage
+ if err = json.Unmarshal(body, &rawFields); err != nil {
+ http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
+ return
+ }
+
type custom struct {
config.ModelConfig
APIKey string `json:"api_key"`
@@ -181,11 +383,6 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
return
}
- if err = mc.Validate(); err != nil {
- http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
- return
- }
-
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
@@ -212,8 +409,61 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
} else if len(mc.ExtraBody) == 0 {
mc.ExtraBody = nil
}
+ // Preserve existing CustomHeaders when omitted (nil), but clear it when
+ // the frontend sends an empty object {} to indicate the field should
+ // be removed.
+ if mc.CustomHeaders == nil {
+ mc.CustomHeaders = cfg.ModelList[idx].CustomHeaders
+ } else if len(mc.CustomHeaders) == 0 {
+ mc.CustomHeaders = nil
+ }
+ if _, ok := rawFields["tool_schema_transform"]; !ok {
+ mc.ToolSchemaTransform = cfg.ModelList[idx].ToolSchemaTransform
+ }
+ // Preserve the existing Provider when the caller omits it. This keeps the
+ // update API backward-compatible for clients that haven't started sending
+ // the new field yet, while still allowing explicit clearing via "".
+ if _, ok := rawFields["provider"]; !ok {
+ mc.Provider = cfg.ModelList[idx].Provider
+ // Older clients still round-trip the legacy model field only. When the
+ // stored config encodes provider/model in Model and has no explicit
+ // Provider field yet, continue preserving that hidden provider prefix.
+ // This keeps provider-omitted updates backward-compatible even when an
+ // older client edits the visible model ID.
+ if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" {
+ existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model)
+ incomingModel := strings.TrimSpace(mc.Model)
+ existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx])
+ if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" {
+ if incomingModel == existingModelID {
+ mc.Model = existingRawModel
+ } else if strings.Contains(incomingModel, "/") && !strings.Contains(existingModelID, "/") {
+ // Older clients never saw the hidden provider prefix for simple
+ // legacy entries such as "openai/gpt-4o". If they now send an
+ // explicit provider/model string, treat it as the caller's full
+ // intent instead of re-applying the old hidden prefix.
+ mc.Model = incomingModel
+ } else if !strings.HasPrefix(incomingModel, existingProtocol+"/") {
+ mc.Model = existingProtocol + "/" + incomingModel
+ }
+ }
+ }
+ }
+
+ normalizeIncomingModelConfig(&mc.ModelConfig)
+ if err = validateIncomingModelConfig(&mc.ModelConfig, cfg.ModelList[idx]); err != nil {
+ http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
+ return
+ }
+ if cfg.Agents.Defaults.ModelName == cfg.ModelList[idx].ModelName &&
+ !defaultModelAllowedForModelConfig(&mc.ModelConfig) {
+ // Allow users to recover from legacy/invalid defaults by saving the model
+ // and clearing the default chat model reference in the same write.
+ cfg.Agents.Defaults.ModelName = ""
+ }
cfg.ModelList[idx] = &mc.ModelConfig
+ normalizeStoredModelProviders(cfg)
logger.Debugf("update model config: %#v", mc.ModelConfig)
@@ -313,6 +563,19 @@ func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request)
http.Error(w, fmt.Sprintf("Cannot set virtual model %q as default", req.ModelName), http.StatusBadRequest)
return
}
+ for _, m := range cfg.ModelList {
+ if m.ModelName == req.ModelName {
+ if !defaultModelAllowedForModelConfig(m) {
+ http.Error(
+ w,
+ fmt.Sprintf("Model %q cannot be used as the default chat model", req.ModelName),
+ http.StatusBadRequest,
+ )
+ return
+ }
+ break
+ }
+ }
cfg.Agents.Defaults.ModelName = req.ModelName
diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go
index 97f153a80..0b1f04848 100644
--- a/web/backend/api/models_test.go
+++ b/web/backend/api/models_test.go
@@ -12,6 +12,7 @@ import (
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
)
func resetModelProbeHooks(t *testing.T) {
@@ -20,14 +21,47 @@ func resetModelProbeHooks(t *testing.T) {
origTCPProbe := probeTCPServiceFunc
origOllamaProbe := probeOllamaModelFunc
origOpenAIProbe := probeOpenAICompatibleModelFunc
+ origCommandProbe := probeCommandAvailableFunc
+ origNow := modelProbeNowFunc
+ resetModelProbeCache()
t.Cleanup(func() {
probeTCPServiceFunc = origTCPProbe
probeOllamaModelFunc = origOllamaProbe
probeOpenAICompatibleModelFunc = origOpenAIProbe
+ probeCommandAvailableFunc = origCommandProbe
+ modelProbeNowFunc = origNow
+ resetModelProbeCache()
})
}
-func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *testing.T) {
+func addModelAndLoadLatest(t *testing.T, configPath string, body string) *config.ModelConfig {
+ t.Helper()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if len(cfg.ModelList) == 0 {
+ t.Fatal("model_list should contain the newly added model")
+ }
+
+ return cfg.ModelList[len(cfg.ModelList)-1]
+}
+
+func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
@@ -90,7 +124,8 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
},
}
cfg.Agents.Defaults.ModelName = "openai-oauth"
- if err := config.SaveConfig(configPath, cfg); err != nil {
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
@@ -109,29 +144,47 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
var resp struct {
Models []modelResponse `json:"models"`
}
- if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ err = json.Unmarshal(rec.Body.Bytes(), &resp)
+ if err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
- got := make(map[string]bool, len(resp.Models))
+ gotAvailable := make(map[string]bool, len(resp.Models))
+ gotStatus := make(map[string]string, len(resp.Models))
for _, model := range resp.Models {
- got[model.ModelName] = model.Configured
+ gotAvailable[model.ModelName] = model.Available
+ gotStatus[model.ModelName] = model.Status
}
- if got["openai-oauth"] {
- t.Fatalf("openai oauth model configured = true, want false without stored credential")
+ if gotAvailable["openai-oauth"] {
+ t.Fatalf("openai oauth model available = true, want false without stored credential")
}
- if !got["vllm-local"] {
- t.Fatalf("vllm local model configured = false, want true when local probe succeeds")
+ if !gotAvailable["vllm-local"] {
+ t.Fatalf("vllm local model available = false, want true when local probe succeeds")
}
- if !got["ollama-default"] {
- t.Fatalf("ollama default model configured = false, want true when default local probe succeeds")
+ if !gotAvailable["ollama-default"] {
+ t.Fatalf("ollama default model available = false, want true when default local probe succeeds")
}
- if !got["vllm-remote"] {
- t.Fatalf("remote vllm model configured = false, want true with api_key")
+ if !gotAvailable["vllm-remote"] {
+ t.Fatalf("remote vllm model available = false, want true with api_key")
}
- if !got["copilot-gpt-5.4"] {
- t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds")
+ if !gotAvailable["copilot-gpt-5.4"] {
+ t.Fatalf("copilot model available = false, want true when local bridge probe succeeds")
+ }
+ if gotStatus["openai-oauth"] != modelStatusUnconfigured {
+ t.Fatalf("openai oauth model status = %q, want %q", gotStatus["openai-oauth"], modelStatusUnconfigured)
+ }
+ if gotStatus["vllm-local"] != modelStatusAvailable {
+ t.Fatalf("vllm local model status = %q, want %q", gotStatus["vllm-local"], modelStatusAvailable)
+ }
+ if gotStatus["ollama-default"] != modelStatusAvailable {
+ t.Fatalf("ollama default model status = %q, want %q", gotStatus["ollama-default"], modelStatusAvailable)
+ }
+ if gotStatus["vllm-remote"] != modelStatusAvailable {
+ t.Fatalf("remote vllm model status = %q, want %q", gotStatus["vllm-remote"], modelStatusAvailable)
+ }
+ if gotStatus["copilot-gpt-5.4"] != modelStatusAvailable {
+ t.Fatalf("copilot model status = %q, want %q", gotStatus["copilot-gpt-5.4"], modelStatusAvailable)
}
if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model|" {
t.Fatalf("openAI probes = %#v, want only local vllm probe", openAIProbes)
@@ -144,7 +197,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
}
}
-func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing.T) {
+func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
@@ -160,14 +213,91 @@ func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing
AuthMethod: "oauth",
}}
cfg.Agents.Defaults.ModelName = "claude-oauth"
- if err := config.SaveConfig(configPath, cfg); err != nil {
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
- if err := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{
+ if setCredentialErr := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{
AccessToken: "anthropic-token",
Provider: oauthProviderAnthropic,
AuthMethod: "oauth",
+ }); setCredentialErr != nil {
+ t.Fatalf("SetCredential() error = %v", setCredentialErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ }
+ err = json.Unmarshal(rec.Body.Bytes(), &resp)
+ if err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(resp.Models))
+ }
+ if !resp.Models[0].Available {
+ t.Fatalf("oauth model available = false, want true with stored credential")
+ }
+}
+
+func TestHasModelConfiguration_OAuthWithoutMappedCredentialFallsBackToAPIKey(t *testing.T) {
+ noKey := &config.ModelConfig{
+ Provider: "gemini",
+ Model: "gemini-2.5-flash",
+ AuthMethod: "oauth",
+ }
+ if hasModelConfiguration(noKey) {
+ t.Fatal("oauth model without credential mapping and api key should be unconfigured")
+ }
+
+ withKey := &config.ModelConfig{
+ Provider: "gemini",
+ Model: "gemini-2.5-flash",
+ AuthMethod: "oauth",
+ APIKeys: config.SimpleSecureStrings("gemini-key"),
+ }
+ if !hasModelConfiguration(withKey) {
+ t.Fatal("oauth model without credential mapping should fall back to api key configuration")
+ }
+}
+
+func TestHandleListModels_AntigravityImplicitOAuthAvailability(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+ resetModelProbeHooks(t)
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "gemini-flash",
+ Provider: "antigravity",
+ Model: "gemini-3-flash",
+ }}
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ if err := auth.SetCredential(oauthProviderGoogleAntigravity, &auth.AuthCredential{
+ AccessToken: "antigravity-token",
+ Provider: oauthProviderGoogleAntigravity,
+ AuthMethod: "oauth",
}); err != nil {
t.Fatalf("SetCredential() error = %v", err)
}
@@ -187,14 +317,158 @@ func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing
var resp struct {
Models []modelResponse `json:"models"`
}
- if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
- t.Fatalf("Unmarshal() error = %v", err)
+ if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil {
+ t.Fatalf("Unmarshal() error = %v", unmarshalErr)
}
if len(resp.Models) != 1 {
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
}
- if !resp.Models[0].Configured {
- t.Fatalf("oauth model configured = false, want true with stored credential")
+ if !resp.Models[0].Available {
+ t.Fatal("antigravity model available = false, want true with stored credential even without auth_method")
+ }
+}
+
+func TestHandleListModels_BedrockUsesAmbientCredentialStatus(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+ resetModelProbeHooks(t)
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "bedrock-claude",
+ Provider: "bedrock",
+ Model: "us.anthropic.claude-sonnet-4-20250514-v1:0",
+ }}
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ }
+ if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil {
+ t.Fatalf("Unmarshal() error = %v", unmarshalErr)
+ }
+ if len(resp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(resp.Models))
+ }
+ if !resp.Models[0].Available {
+ t.Fatal("bedrock model available = false, want true because Bedrock uses ambient AWS credentials")
+ }
+ if resp.Models[0].Status != modelStatusAvailable {
+ t.Fatalf("bedrock model status = %q, want %q", resp.Models[0].Status, modelStatusAvailable)
+ }
+}
+
+func TestHandleListModels_CLIProvidersRequireInstalledCommands(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+ resetModelProbeHooks(t)
+
+ probeCommandAvailableFunc = func(command string) bool {
+ switch command {
+ case "claude":
+ return false
+ case "codex":
+ return true
+ default:
+ return false
+ }
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{
+ {
+ ModelName: "claude-cli-model",
+ Provider: "claude-cli",
+ Model: "claude-cli",
+ },
+ {
+ ModelName: "codex-cli-model",
+ Provider: "codex-cli",
+ Model: "codex-cli",
+ },
+ }
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ ProviderOptions []providers.ModelProviderOption `json:"provider_options"`
+ }
+ if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil {
+ t.Fatalf("Unmarshal() error = %v", unmarshalErr)
+ }
+
+ modelsByName := make(map[string]modelResponse, len(resp.Models))
+ for _, model := range resp.Models {
+ modelsByName[model.ModelName] = model
+ }
+ if model := modelsByName["claude-cli-model"]; model.Available || model.Status != modelStatusUnreachable {
+ t.Fatalf(
+ "claude-cli status = (%t, %q), want (%t, %q)",
+ model.Available,
+ model.Status,
+ false,
+ modelStatusUnreachable,
+ )
+ }
+ if model := modelsByName["codex-cli-model"]; !model.Available || model.Status != modelStatusAvailable {
+ t.Fatalf(
+ "codex-cli status = (%t, %q), want (%t, %q)",
+ model.Available,
+ model.Status,
+ true,
+ modelStatusAvailable,
+ )
+ }
+
+ optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions))
+ for _, option := range resp.ProviderOptions {
+ optionsByID[option.ID] = option
+ }
+ if option, ok := optionsByID["claude-cli"]; !ok {
+ t.Fatal("claude-cli provider option missing")
+ } else if option.CreateAllowed {
+ t.Fatal("claude-cli should not be creatable when the claude command is missing")
+ }
+ if option, ok := optionsByID["codex-cli"]; !ok {
+ t.Fatal("codex-cli provider option missing")
+ } else if !option.CreateAllowed {
+ t.Fatal("codex-cli should be creatable when the codex command is available")
}
}
@@ -297,6 +571,59 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ }
+ if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil {
+ t.Fatalf("Unmarshal() error = %v", unmarshalErr)
+ }
+ if len(resp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(resp.Models))
+ }
+ if !resp.Models[0].Available {
+ t.Fatal("wildcard-bound local model available = false, want true after probe host normalization")
+ }
+ if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" {
+ t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|")
+ }
+}
+
+func TestHandleListModels_StatusMarksUnreachableLocalModel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+ resetModelProbeHooks(t)
+
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ return false
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "vllm-local-down",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ APIKeys: config.SimpleSecureStrings("test-key"),
+ }}
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
var resp struct {
Models []modelResponse `json:"models"`
}
@@ -306,11 +633,58 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) {
if len(resp.Models) != 1 {
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
}
- if !resp.Models[0].Configured {
- t.Fatal("wildcard-bound local model configured = false, want true after probe host normalization")
+
+ if resp.Models[0].Available {
+ t.Fatal("unreachable local model available = true, want false")
}
+ if resp.Models[0].Status != modelStatusUnreachable {
+ t.Fatalf("unreachable local model status = %q, want %q", resp.Models[0].Status, modelStatusUnreachable)
+ }
+ if resp.Models[0].APIKey == "" {
+ t.Fatal("masked API key preview should still be returned when API key is configured")
+ }
+}
+
+func TestHandleListModels_RuntimeProbeUsesExplicitProviderField(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+ resetModelProbeHooks(t)
+
+ var gotProbe string
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ gotProbe = apiBase + "|" + modelID + "|" + apiKey
+ return true
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "vllm-local",
+ Provider: "vllm",
+ Model: "custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ }}
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" {
- t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|")
+ t.Fatalf("probe = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|")
}
}
@@ -352,6 +726,1350 @@ func TestHandleAddModel_PersistsAPIKey(t *testing.T) {
}
}
+func TestHandleAddModel_PersistsProvider(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"nvidia-glm",
+ "provider":"nvidia",
+ "model":"z-ai/glm-5.1",
+ "api_key":"nv-key"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ added := cfg.ModelList[len(cfg.ModelList)-1]
+ if added.Provider != "nvidia" {
+ t.Fatalf("provider = %q, want %q", added.Provider, "nvidia")
+ }
+ if added.Model != "z-ai/glm-5.1" {
+ t.Fatalf("model = %q, want %q", added.Model, "z-ai/glm-5.1")
+ }
+}
+
+func TestHandleAddModel_RejectsUnsupportedProvider(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"bad-provider",
+ "provider":"not-supported",
+ "model":"gpt-4o-mini"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), `provider "not-supported" is not supported`) {
+ t.Fatalf("body = %q, want unsupported provider error", rec.Body.String())
+ }
+}
+
+func TestHandleAddModel_AllowsBedrockProvider(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"bedrock-claude",
+ "provider":"bedrock",
+ "model":"us.anthropic.claude-sonnet-4-20250514-v1:0"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ added := cfg.ModelList[len(cfg.ModelList)-1]
+ if got := added.Provider; got != "bedrock" {
+ t.Fatalf("provider = %q, want %q", got, "bedrock")
+ }
+ if got := added.Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" {
+ t.Fatalf("model = %q, want bedrock model ID", got)
+ }
+}
+
+func TestHandleAddModel_NormalizesLegacyElevenLabsASRConfig(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "elevenlabs-asr",
+ Model: "elevenlabs/scribe_v1",
+ APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"new-model",
+ "provider":"openai",
+ "model":"gpt-4o-mini",
+ "api_key":"sk-new-model-key"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if len(updated.ModelList) != 2 {
+ t.Fatalf("len(model_list) = %d, want 2", len(updated.ModelList))
+ }
+ if got := updated.ModelList[0].Provider; got != "elevenlabs" {
+ t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs")
+ }
+ if got := updated.ModelList[0].Model; got != "scribe_v1" {
+ t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1")
+ }
+}
+
+func TestHandleAddModel_NormalizesExplicitElevenLabsUnsupportedModelID(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "elevenlabs-asr",
+ Provider: "elevenlabs",
+ Model: "scribe_v2",
+ APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"new-model",
+ "provider":"openai",
+ "model":"gpt-4o-mini",
+ "api_key":"sk-new-model-key"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "elevenlabs" {
+ t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs")
+ }
+ if got := updated.ModelList[0].Model; got != "scribe_v1" {
+ t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1")
+ }
+}
+
+func TestHandleAddModel_RejectsMissingCLIProviderCommand(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+ resetModelProbeHooks(t)
+
+ probeCommandAvailableFunc = func(command string) bool {
+ return false
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"claude-cli-model",
+ "provider":"claude-cli",
+ "model":"claude-cli"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), `provider "claude-cli" is not available for new models`) {
+ t.Fatalf("body = %q, want missing cli command error", rec.Body.String())
+ }
+}
+
+func TestHandleAddModel_DefaultsAntigravityToOAuth(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ added := addModelAndLoadLatest(t, configPath, `{
+ "model_name":"gemini-flash",
+ "provider":"antigravity",
+ "model":"gemini-3-flash"
+ }`)
+ if got := added.AuthMethod; got != "oauth" {
+ t.Fatalf("auth_method = %q, want %q", got, "oauth")
+ }
+}
+
+func TestHandleAddModel_NormalizesMixedCaseAuthMethod(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ added := addModelAndLoadLatest(t, configPath, `{
+ "model_name":"openai-oauth",
+ "provider":"openai",
+ "model":"gpt-5.4",
+ "auth_method":"OAuth"
+ }`)
+ if got := added.AuthMethod; got != "oauth" {
+ t.Fatalf("auth_method = %q, want %q", got, "oauth")
+ }
+}
+
+func TestHandleAddModel_PreservesExplicitProviderPrefixedModel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"openai-gpt",
+ "provider":"openai",
+ "model":"openai/gpt-4o-mini",
+ "api_key":"sk-openai"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ added := cfg.ModelList[len(cfg.ModelList)-1]
+ if got := added.Provider; got != "openai" {
+ t.Fatalf("provider = %q, want %q", got, "openai")
+ }
+ if got := added.Model; got != "openai/gpt-4o-mini" {
+ t.Fatalf("model = %q, want %q", got, "openai/gpt-4o-mini")
+ }
+}
+
+func TestHandleAddModel_PersistsCustomHeaders(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"new-model-headers",
+ "model":"openai/gpt-4o-mini",
+ "custom_headers":{"X-Source":"coding-plan","X-Agent":"openclaw"}
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if len(cfg.ModelList) != 2 {
+ t.Fatalf("len(model_list) = %d, want 2", len(cfg.ModelList))
+ }
+
+ added := cfg.ModelList[1]
+ if added.CustomHeaders == nil {
+ t.Fatal("custom_headers should not be nil")
+ }
+ if got := added.CustomHeaders["X-Source"]; got != "coding-plan" {
+ t.Fatalf("custom_headers[X-Source] = %q, want %q", got, "coding-plan")
+ }
+ if got := added.CustomHeaders["X-Agent"]; got != "openclaw" {
+ t.Fatalf("custom_headers[X-Agent] = %q, want %q", got, "openclaw")
+ }
+}
+
+func TestHandleAddModel_PersistsToolSchemaTransform(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"new-model-transform",
+ "model":"openai/gpt-4o-mini",
+ "tool_schema_transform":"simple"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ added := cfg.ModelList[len(cfg.ModelList)-1]
+ if got := added.ToolSchemaTransform; got != "simple" {
+ t.Fatalf("tool_schema_transform = %q, want %q", got, "simple")
+ }
+}
+
+func TestHandleUpdateModel_CustomHeadersPreserveAndClear(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "editable",
+ Model: "openai/gpt-4o-mini",
+ APIKeys: config.SimpleSecureStrings("sk-existing"),
+ CustomHeaders: map[string]string{"X-Source": "coding-plan"},
+ }}
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ // Omitted custom_headers should preserve existing value.
+ recPreserve := httptest.NewRecorder()
+ reqPreserve := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"editable",
+ "model":"openai/gpt-4o-mini"
+ }`))
+ reqPreserve.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(recPreserve, reqPreserve)
+ if recPreserve.Code != http.StatusOK {
+ t.Fatalf("preserve status = %d, want %d, body=%s", recPreserve.Code, http.StatusOK, recPreserve.Body.String())
+ }
+
+ afterPreserve, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() after preserve error = %v", err)
+ }
+ if got := afterPreserve.ModelList[0].CustomHeaders["X-Source"]; got != "coding-plan" {
+ t.Fatalf("preserved custom_headers[X-Source] = %q, want %q", got, "coding-plan")
+ }
+
+ // Empty object should clear custom_headers.
+ recClear := httptest.NewRecorder()
+ reqClear := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"editable",
+ "model":"openai/gpt-4o-mini",
+ "custom_headers":{}
+ }`))
+ reqClear.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(recClear, reqClear)
+ if recClear.Code != http.StatusOK {
+ t.Fatalf("clear status = %d, want %d, body=%s", recClear.Code, http.StatusOK, recClear.Body.String())
+ }
+
+ afterClear, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() after clear error = %v", err)
+ }
+ if afterClear.ModelList[0].CustomHeaders != nil {
+ t.Fatalf("custom_headers = %#v, want nil", afterClear.ModelList[0].CustomHeaders)
+ }
+}
+
+func TestHandleUpdateModel_ToolSchemaTransformPreserveAndClear(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "editable",
+ Model: "openai/gpt-4o-mini",
+ APIKeys: config.SimpleSecureStrings("sk-existing"),
+ ToolSchemaTransform: "simple",
+ }}
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ recPreserve := httptest.NewRecorder()
+ reqPreserve := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"editable",
+ "model":"openai/gpt-4o-mini"
+ }`))
+ reqPreserve.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(recPreserve, reqPreserve)
+ if recPreserve.Code != http.StatusOK {
+ t.Fatalf("preserve status = %d, want %d, body=%s", recPreserve.Code, http.StatusOK, recPreserve.Body.String())
+ }
+
+ afterPreserve, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() after preserve error = %v", err)
+ }
+ if got := afterPreserve.ModelList[0].ToolSchemaTransform; got != "simple" {
+ t.Fatalf("preserved tool_schema_transform = %q, want %q", got, "simple")
+ }
+
+ recClear := httptest.NewRecorder()
+ reqClear := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"editable",
+ "model":"openai/gpt-4o-mini",
+ "tool_schema_transform":""
+ }`))
+ reqClear.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(recClear, reqClear)
+ if recClear.Code != http.StatusOK {
+ t.Fatalf("clear status = %d, want %d, body=%s", recClear.Code, http.StatusOK, recClear.Body.String())
+ }
+
+ afterClear, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() after clear error = %v", err)
+ }
+ if afterClear.ModelList[0].ToolSchemaTransform != "" {
+ t.Fatalf("tool_schema_transform = %q, want empty", afterClear.ModelList[0].ToolSchemaTransform)
+ }
+}
+
+func TestHandleUpdateModel_PersistsProvider(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "editable",
+ Model: "gpt-4o",
+ Provider: "openai",
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"editable",
+ "provider":"openrouter",
+ "model":"openai/gpt-4o"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "openrouter" {
+ t.Fatalf("provider = %q, want %q", got, "openrouter")
+ }
+}
+
+func TestHandleUpdateModel_PreservesExplicitProviderPrefixedModel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "editable",
+ Model: "gpt-4o",
+ Provider: "openai",
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"editable",
+ "provider":"openai",
+ "model":"openai/gpt-5.4"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "openai" {
+ t.Fatalf("provider = %q, want %q", got, "openai")
+ }
+ if got := updated.ModelList[0].Model; got != "openai/gpt-5.4" {
+ t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4")
+ }
+}
+
+func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "openrouter-auto-explicit",
+ Provider: "openrouter",
+ Model: "openrouter/auto",
+ }}
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ }
+ err = json.Unmarshal(rec.Body.Bytes(), &resp)
+ if err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(resp.Models))
+ }
+ if got := resp.Models[0].Provider; got != "openrouter" {
+ t.Fatalf("provider = %q, want %q", got, "openrouter")
+ }
+ if got := resp.Models[0].Model; got != "openrouter/auto" {
+ t.Fatalf("model = %q, want %q", got, "openrouter/auto")
+ }
+}
+
+func TestHandleListModels_ExposesElevenLabsASRProvider(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "elevenlabs-asr",
+ Model: "elevenlabs/scribe_v1",
+ APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ }
+ if err = json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(resp.Models))
+ }
+ if got := resp.Models[0].Provider; got != "elevenlabs" {
+ t.Fatalf("provider = %q, want %q", got, "elevenlabs")
+ }
+ if got := resp.Models[0].Model; got != "scribe_v1" {
+ t.Fatalf("model = %q, want %q", got, "scribe_v1")
+ }
+ if resp.Models[0].DefaultModelAllowed {
+ t.Fatal("elevenlabs ASR model should not be allowed as the default chat model")
+ }
+}
+
+func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "legacy-openrouter",
+ Model: "openrouter/openai/gpt-5.4",
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ // Simulate an older client: it reads GET /api/models, ignores the new
+ // provider field, then PUTs the visible model string back unchanged.
+ recList := httptest.NewRecorder()
+ reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(recList, reqList)
+
+ if recList.Code != http.StatusOK {
+ t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String())
+ }
+
+ var listResp struct {
+ Models []modelResponse `json:"models"`
+ }
+ if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(listResp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(listResp.Models))
+ }
+ if got := listResp.Models[0].Provider; got != "openrouter" {
+ t.Fatalf("provider = %q, want %q", got, "openrouter")
+ }
+ if got := listResp.Models[0].Model; got != "openai/gpt-5.4" {
+ t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4")
+ }
+
+ recUpdate := httptest.NewRecorder()
+ reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"legacy-openrouter",
+ "model":"openai/gpt-5.4"
+ }`))
+ reqUpdate.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(recUpdate, reqUpdate)
+
+ if recUpdate.Code != http.StatusOK {
+ t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "openrouter" {
+ t.Fatalf("provider = %q, want %q", got, "openrouter")
+ }
+ if got := updated.ModelList[0].Model; got != "openai/gpt-5.4" {
+ t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4")
+ }
+}
+
+func TestHandleUpdateModel_MigratesLegacyElevenLabsASRWhenProviderOmitted(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "elevenlabs-asr",
+ Model: "elevenlabs/scribe_v1",
+ APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ recList := httptest.NewRecorder()
+ reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(recList, reqList)
+
+ if recList.Code != http.StatusOK {
+ t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String())
+ }
+
+ var listResp struct {
+ Models []modelResponse `json:"models"`
+ }
+ if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(listResp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(listResp.Models))
+ }
+ if got := listResp.Models[0].Provider; got != "elevenlabs" {
+ t.Fatalf("provider = %q, want %q", got, "elevenlabs")
+ }
+ if got := listResp.Models[0].Model; got != "scribe_v1" {
+ t.Fatalf("model = %q, want %q", got, "scribe_v1")
+ }
+
+ recUpdate := httptest.NewRecorder()
+ reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"elevenlabs-asr",
+ "model":"scribe_v1",
+ "api_base":"https://api.elevenlabs.io"
+ }`))
+ reqUpdate.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(recUpdate, reqUpdate)
+
+ if recUpdate.Code != http.StatusOK {
+ t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "elevenlabs" {
+ t.Fatalf("provider = %q, want %q", got, "elevenlabs")
+ }
+ if got := updated.ModelList[0].Model; got != "scribe_v1" {
+ t.Fatalf("model = %q, want %q", got, "scribe_v1")
+ }
+ if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" {
+ t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io")
+ }
+}
+
+func TestHandleUpdateModel_RoundTripsExplicitLegacyElevenLabsModelID(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "elevenlabs-asr",
+ Provider: "elevenlabs",
+ Model: "scribe_v2",
+ APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ recList := httptest.NewRecorder()
+ reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(recList, reqList)
+
+ if recList.Code != http.StatusOK {
+ t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String())
+ }
+
+ var listResp struct {
+ Models []modelResponse `json:"models"`
+ }
+ if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(listResp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(listResp.Models))
+ }
+ if got := listResp.Models[0].Provider; got != "elevenlabs" {
+ t.Fatalf("provider = %q, want %q", got, "elevenlabs")
+ }
+ if got := listResp.Models[0].Model; got != "scribe_v1" {
+ t.Fatalf("model = %q, want %q after GET normalization", got, "scribe_v1")
+ }
+
+ recUpdate := httptest.NewRecorder()
+ reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"elevenlabs-asr",
+ "provider":"elevenlabs",
+ "model":"scribe_v1",
+ "api_base":"https://api.elevenlabs.io"
+ }`))
+ reqUpdate.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(recUpdate, reqUpdate)
+
+ if recUpdate.Code != http.StatusOK {
+ t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "elevenlabs" {
+ t.Fatalf("provider = %q, want %q", got, "elevenlabs")
+ }
+ if got := updated.ModelList[0].Model; got != "scribe_v1" {
+ t.Fatalf("model = %q, want %q", got, "scribe_v1")
+ }
+ if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" {
+ t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io")
+ }
+}
+
+func TestHandleUpdateModel_ClearsDefaultWhenSavingASROnlyModel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "elevenlabs-asr",
+ Provider: "elevenlabs",
+ Model: "scribe_v1",
+ APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
+ }}
+ cfg.Agents.Defaults.ModelName = "elevenlabs-asr"
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"elevenlabs-asr",
+ "provider":"elevenlabs",
+ "model":"scribe_v1",
+ "api_base":"https://api.elevenlabs.io"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.Agents.Defaults.ModelName; got != "" {
+ t.Fatalf("default model = %q, want cleared default", got)
+ }
+}
+
+func TestHandleAddModel_RejectsUnsupportedElevenLabsModelID(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
+ "model_name":"elevenlabs-asr",
+ "provider":"elevenlabs",
+ "model":"scribe_v2"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), `provider "elevenlabs" only supports model "scribe_v1"`) {
+ t.Fatalf("body = %q, want elevenlabs model validation error", rec.Body.String())
+ }
+}
+
+func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModelChanges(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "legacy-openrouter",
+ Model: "openrouter/openai/gpt-5.4",
+ }}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"legacy-openrouter",
+ "model":"openai/gpt-5.5"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "openrouter" {
+ t.Fatalf("provider = %q, want %q", got, "openrouter")
+ }
+ if got := updated.ModelList[0].Model; got != "openai/gpt-5.5" {
+ t.Fatalf("model = %q, want %q", got, "openai/gpt-5.5")
+ }
+}
+
+func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "legacy-openrouter",
+ Model: "openrouter/openai/gpt-5.4",
+ }}
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ ProviderOptions []providers.ModelProviderOption `json:"provider_options"`
+ }
+ if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil {
+ t.Fatalf("Unmarshal() error = %v", unmarshalErr)
+ }
+ if len(resp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(resp.Models))
+ }
+ if got := resp.Models[0].Provider; got != "openrouter" {
+ t.Fatalf("provider = %q, want %q", got, "openrouter")
+ }
+ if got := resp.Models[0].Model; got != "openai/gpt-5.4" {
+ t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4")
+ }
+
+ optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions))
+ for _, option := range resp.ProviderOptions {
+ optionsByID[option.ID] = option
+ }
+ if len(optionsByID) == 0 {
+ t.Fatal("provider_options should not be empty")
+ }
+ if option, ok := optionsByID["openai"]; !ok {
+ t.Fatal("openai provider option missing")
+ } else if option.DefaultAPIBase != "https://api.openai.com/v1" {
+ t.Fatalf("openai default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.openai.com/v1")
+ }
+ if option, ok := optionsByID["anthropic"]; !ok {
+ t.Fatal("anthropic provider option missing")
+ } else if option.DefaultAPIBase != "https://api.anthropic.com/v1" {
+ t.Fatalf("anthropic default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.anthropic.com/v1")
+ }
+ if _, ok := optionsByID["azure"]; !ok {
+ t.Fatal("azure provider option missing")
+ }
+ if option, ok := optionsByID["github-copilot"]; !ok {
+ t.Fatal("github-copilot provider option missing")
+ } else if option.DefaultAPIBase != "localhost:4321" {
+ t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321")
+ }
+ if option, ok := optionsByID["elevenlabs"]; !ok {
+ t.Fatal("elevenlabs provider option missing")
+ } else {
+ if option.DefaultAPIBase != "https://api.elevenlabs.io" {
+ t.Fatalf("elevenlabs default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.elevenlabs.io")
+ }
+ if option.DefaultModelAllowed {
+ t.Fatal("elevenlabs should be marked as not allowed for default chat model selection")
+ }
+ }
+ if option, ok := optionsByID["lmstudio"]; !ok {
+ t.Fatal("lmstudio provider option missing")
+ } else if !option.EmptyAPIKeyAllowed {
+ t.Fatal("lmstudio should allow empty api keys")
+ }
+ if option, ok := optionsByID["bedrock"]; !ok {
+ t.Fatal("bedrock provider option missing")
+ } else if !option.CreateAllowed {
+ t.Fatal("bedrock should stay creatable and defer AWS credential failures to runtime")
+ }
+ if option, ok := optionsByID["antigravity"]; !ok {
+ t.Fatal("antigravity provider option missing")
+ } else {
+ if option.DefaultAuthMethod != "oauth" {
+ t.Fatalf("antigravity default_auth_method = %q, want %q", option.DefaultAuthMethod, "oauth")
+ }
+ if !option.AuthMethodLocked {
+ t.Fatal("antigravity auth method should be locked")
+ }
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "" {
+ t.Fatalf("persisted provider = %q, want unchanged empty provider", got)
+ }
+ if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" {
+ t.Fatalf("persisted model = %q, want unchanged legacy model", got)
+ }
+}
+
+func TestHandleListModels_ReturnsProviderField(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "nvidia-glm",
+ Provider: "nvidia",
+ Model: "z-ai/glm-5.1",
+ APIKeys: config.SimpleSecureStrings("nv-key"),
+ }}
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(resp.Models))
+ }
+ if got := resp.Models[0].Provider; got != "nvidia" {
+ t.Fatalf("provider = %q, want %q", got, "nvidia")
+ }
+}
+
+func TestHandleListModels_PreservesKnownProviderInCatalog(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "bedrock-claude",
+ Model: "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0",
+ }}
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ ProviderOptions []providers.ModelProviderOption `json:"provider_options"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(resp.Models))
+ }
+ if got := resp.Models[0].Provider; got != "bedrock" {
+ t.Fatalf("provider = %q, want %q", got, "bedrock")
+ }
+ if got := resp.Models[0].Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" {
+ t.Fatalf("model = %q, want %q", got, "us.anthropic.claude-sonnet-4-20250514-v1:0")
+ }
+ foundBedrock := false
+ for _, option := range resp.ProviderOptions {
+ if option.ID == "bedrock" {
+ foundBedrock = true
+ if !option.CreateAllowed {
+ t.Fatal("bedrock should stay creatable in provider_options")
+ }
+ }
+ }
+ if !foundBedrock {
+ t.Fatal("bedrock should be included in provider_options for compatibility")
+ }
+}
+
+func TestHandleUpdateModel_AllowsExistingBedrockProvider(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "bedrock-claude",
+ Provider: "bedrock",
+ Model: "us.anthropic.claude-sonnet-4-20250514-v1:0",
+ APIBase: "us-west-2",
+ }}
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
+ "model_name":"bedrock-claude",
+ "provider":"bedrock",
+ "model":"us.anthropic.claude-3-7-sonnet-20250219-v1:0",
+ "api_base":"us-east-1"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.ModelList[0].Provider; got != "bedrock" {
+ t.Fatalf("provider = %q, want %q", got, "bedrock")
+ }
+ if got := updated.ModelList[0].Model; got != "us.anthropic.claude-3-7-sonnet-20250219-v1:0" {
+ t.Fatalf("model = %q, want updated bedrock model", got)
+ }
+ if got := updated.ModelList[0].APIBase; got != "us-east-1" {
+ t.Fatalf("api_base = %q, want %q", got, "us-east-1")
+ }
+}
+
+func TestHandleListModels_ReturnsEffectiveProviderField(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{
+ {
+ ModelName: "plain-openai",
+ Model: "gpt-4o",
+ },
+ {
+ ModelName: "explicit-google",
+ Provider: "google",
+ Model: "gemini-2.5-pro",
+ },
+ {
+ ModelName: "explicit-qwen-intl",
+ Provider: "qwen-international",
+ Model: "qwen3-coder-plus",
+ },
+ }
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ if len(resp.Models) != 3 {
+ t.Fatalf("len(models) = %d, want 3", len(resp.Models))
+ }
+
+ if got := resp.Models[0].Provider; got != "openai" {
+ t.Fatalf("provider[0] = %q, want %q", got, "openai")
+ }
+ if got := resp.Models[0].Model; got != "gpt-4o" {
+ t.Fatalf("model[0] = %q, want %q", got, "gpt-4o")
+ }
+ if got := resp.Models[1].Provider; got != "gemini" {
+ t.Fatalf("provider[1] = %q, want %q", got, "gemini")
+ }
+ if got := resp.Models[1].Model; got != "gemini-2.5-pro" {
+ t.Fatalf("model[1] = %q, want %q", got, "gemini-2.5-pro")
+ }
+ if got := resp.Models[2].Provider; got != "qwen-intl" {
+ t.Fatalf("provider[2] = %q, want %q", got, "qwen-intl")
+ }
+ if got := resp.Models[2].Model; got != "qwen3-coder-plus" {
+ t.Fatalf("model[2] = %q, want %q", got, "qwen3-coder-plus")
+ }
+}
+
// TestHandleSetDefaultModel_RejectsNonexistentModel tests that setting a non-existent
// model as default returns 404. This covers the case where virtual models (which are
// filtered by SaveConfig) cannot be set as default.
@@ -392,6 +2110,45 @@ func TestHandleSetDefaultModel_RejectsNonexistentModel(t *testing.T) {
}
}
+func TestHandleSetDefaultModel_RejectsElevenLabsASRProvider(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{
+ {
+ ModelName: "elevenlabs-asr",
+ Provider: "elevenlabs",
+ Model: "scribe_v1",
+ APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
+ },
+ }
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models/default", bytes.NewBufferString(`{
+ "model_name": "elevenlabs-asr"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "cannot be used as the default chat model") {
+ t.Fatalf("body = %q, want default chat model rejection", rec.Body.String())
+ }
+}
+
func TestMaskAPIKey(t *testing.T) {
tests := []struct {
name string
diff --git a/web/backend/api/oauth.go b/web/backend/api/oauth.go
index 213b53836..116e304b1 100644
--- a/web/backend/api/oauth.go
+++ b/web/backend/api/oauth.go
@@ -746,7 +746,7 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {
found := false
for i := range cfg.ModelList {
- if modelBelongsToProvider(provider, cfg.ModelList[i].Model) {
+ if modelBelongsToProvider(provider, cfg.ModelList[i]) {
cfg.ModelList[i].AuthMethod = authMethod
found = true
}
@@ -759,18 +759,15 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {
return oauthSaveConfig(h.configPath, cfg)
}
-func modelBelongsToProvider(provider, model string) bool {
- lower := strings.ToLower(strings.TrimSpace(model))
+func modelBelongsToProvider(provider string, modelCfg *config.ModelConfig) bool {
+ protocol, _ := providers.ExtractProtocol(modelCfg)
switch provider {
case oauthProviderOpenAI:
- return lower == "openai" || strings.HasPrefix(lower, "openai/")
+ return protocol == "openai"
case oauthProviderAnthropic:
- return lower == "anthropic" || strings.HasPrefix(lower, "anthropic/")
+ return protocol == "anthropic"
case oauthProviderGoogleAntigravity:
- return lower == "antigravity" ||
- lower == "google-antigravity" ||
- strings.HasPrefix(lower, "antigravity/") ||
- strings.HasPrefix(lower, "google-antigravity/")
+ return protocol == "antigravity" || protocol == "google-antigravity"
default:
return false
}
@@ -781,19 +778,22 @@ func defaultModelConfigForProvider(provider, authMethod string) *config.ModelCon
case oauthProviderOpenAI:
return &config.ModelConfig{
ModelName: "gpt-5.4",
- Model: "openai/gpt-5.4",
+ Provider: "openai",
+ Model: "gpt-5.4",
AuthMethod: authMethod,
}
case oauthProviderAnthropic:
return &config.ModelConfig{
ModelName: "claude-sonnet-4.6",
- Model: "anthropic/claude-sonnet-4.6",
+ Provider: "anthropic",
+ Model: "claude-sonnet-4.6",
AuthMethod: authMethod,
}
case oauthProviderGoogleAntigravity:
return &config.ModelConfig{
ModelName: "gemini-flash",
- Model: "antigravity/gemini-3-flash",
+ Provider: "antigravity",
+ Model: "gemini-3-flash",
AuthMethod: authMethod,
}
default:
diff --git a/web/backend/api/oauth_test.go b/web/backend/api/oauth_test.go
index 5aaff8d8f..9468c8873 100644
--- a/web/backend/api/oauth_test.go
+++ b/web/backend/api/oauth_test.go
@@ -214,6 +214,54 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
}
}
+func TestOAuthLogoutClearsAuthMethodForExplicitProviderField(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig error: %v", err)
+ }
+ cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{
+ ModelName: "gpt-5.4",
+ Provider: "openai",
+ Model: "gpt-5.4",
+ AuthMethod: "oauth",
+ })
+ if err = config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig error: %v", err)
+ }
+ if err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{
+ AccessToken: "token-before-logout",
+ Provider: oauthProviderOpenAI,
+ AuthMethod: "oauth",
+ }); err != nil {
+ t.Fatalf("SetCredential error: %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/oauth/logout", bytes.NewBufferString(`{"provider":"openai"}`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig error: %v", err)
+ }
+ if got := updated.ModelList[len(updated.ModelList)-1].AuthMethod; got != "" {
+ t.Fatalf("auth_method = %q, want empty", got)
+ }
+}
+
func setupOAuthTestEnv(t *testing.T) (string, func()) {
t.Helper()
diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go
index a3f1a4ffb..8eeff4041 100644
--- a/web/backend/api/pico.go
+++ b/web/backend/api/pico.go
@@ -10,11 +10,13 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ ppid "github.com/sipeed/picoclaw/pkg/pid"
)
// registerPicoRoutes binds Pico Channel management endpoints to the ServeMux.
func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
- mux.HandleFunc("GET /api/pico/token", h.handleGetPicoToken)
+ mux.HandleFunc("GET /api/pico/info", h.handleGetPicoInfo)
mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken)
mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup)
@@ -22,48 +24,191 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
// This allows the frontend to connect via the same port as the web UI,
// avoiding the need to expose extra ports for WebSocket communication.
mux.HandleFunc("GET /pico/ws", h.handleWebSocketProxy())
+ mux.HandleFunc("GET /pico/media/{id}", h.handlePicoMediaProxy())
+ mux.HandleFunc("HEAD /pico/media/{id}", h.handlePicoMediaProxy())
}
// createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint.
// The gateway bind host and port are resolved from the latest configuration.
-func (h *Handler) createWsProxy() *httputil.ReverseProxy {
- wsProxy := httputil.NewSingleHostReverseProxy(h.gatewayProxyURL())
- wsProxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
- http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway)
+func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *httputil.ReverseProxy {
+ wsProxy := &httputil.ReverseProxy{
+ Rewrite: func(r *httputil.ProxyRequest) {
+ target := h.gatewayProxyURL()
+ r.SetURL(target)
+ r.Out.Header.Del(protocolKey)
+ if upstreamProtocol != "" {
+ r.Out.Header.Set(protocolKey, upstreamProtocol)
+ }
+ },
+ ModifyResponse: func(r *http.Response) error {
+ if prot := r.Header.Values(protocolKey); len(prot) > 0 {
+ r.Header.Del(protocolKey)
+ if origProtocol != "" {
+ r.Header.Set(protocolKey, origProtocol)
+ }
+ }
+ return nil
+ },
+ ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
+ logger.Errorf("Failed to proxy WebSocket: %v", err)
+ http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway)
+ },
}
return wsProxy
}
-// handleWebSocketProxy wraps a reverse proxy to handle WebSocket connections.
-// The reverse proxy forwards the incoming upgrade handshake as-is.
-func (h *Handler) handleWebSocketProxy() http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- proxy := h.createWsProxy()
- proxy.ServeHTTP(w, r)
+func (h *Handler) createPicoHTTPProxy(token string) *httputil.ReverseProxy {
+ return &httputil.ReverseProxy{
+ Rewrite: func(r *httputil.ProxyRequest) {
+ target := h.gatewayProxyURL()
+ r.SetURL(target)
+ r.Out.Header.Set("Authorization", "Bearer "+token)
+ },
+ ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
+ logger.Errorf("Failed to proxy Pico HTTP request: %v", err)
+ http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway)
+ },
}
}
-// handleGetPicoToken returns the current WS token and URL for the frontend.
+func (h *Handler) gatewayAvailableForProxy() bool {
+ gateway.mu.Lock()
+ ensurePicoTokenCachedLocked(h.configPath)
+ cachedPID := gateway.pidData
+ trackedCmd := gateway.cmd
+ gateway.mu.Unlock()
+
+ if pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil); pidData != nil {
+ gateway.mu.Lock()
+ gateway.pidData = pidData
+ setGatewayRuntimeStatusLocked("running")
+ gateway.mu.Unlock()
+ return true
+ }
+
+ if cachedPID == nil {
+ return false
+ }
+
+ if isCmdProcessAliveLocked(trackedCmd) {
+ return true
+ }
+
+ gateway.mu.Lock()
+ if gateway.cmd == trackedCmd {
+ gateway.pidData = nil
+ setGatewayRuntimeStatusLocked("stopped")
+ }
+ available := gateway.pidData != nil
+ gateway.mu.Unlock()
+ return available
+}
+
+func decodePicoSettings(cfg *config.Config) (config.PicoSettings, bool) {
+ if cfg == nil {
+ return config.PicoSettings{}, false
+ }
+
+ bc := cfg.Channels.GetByType(config.ChannelPico)
+ if bc == nil {
+ return config.PicoSettings{}, false
+ }
+
+ var picoCfg config.PicoSettings
+ if err := bc.Decode(&picoCfg); err != nil {
+ return config.PicoSettings{}, false
+ }
+
+ return picoCfg, bc.Enabled
+}
+
+func (h *Handler) writePicoInfoResponse(
+ w http.ResponseWriter,
+ r *http.Request,
+ cfg *config.Config,
+ changed *bool,
+) {
+ picoCfg, enabled := decodePicoSettings(cfg)
+
+ resp := map[string]any{
+ "ws_url": h.buildWsURL(r),
+ "enabled": enabled,
+ }
+ if changed != nil {
+ resp["changed"] = *changed
+ }
+ if picoCfg.Token.String() != "" {
+ resp["configured"] = true
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+}
+
+// handleWebSocketProxy wraps a reverse proxy to handle WebSocket connections.
+// It relies on launcher dashboard auth, then injects the raw pico token only
+// on the upstream gateway request.
+func (h *Handler) handleWebSocketProxy() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !h.gatewayAvailableForProxy() {
+ logger.Warnf("Gateway not available for WebSocket proxy")
+ http.Error(w, "Gateway not available", http.StatusServiceUnavailable)
+ return
+ }
+
+ upstreamProtocol := picoGatewayProtocol()
+ if upstreamProtocol == "" {
+ logger.Warn("Pico token unavailable for WebSocket proxy")
+ http.Error(w, "Pico channel not configured", http.StatusServiceUnavailable)
+ return
+ }
+
+ var origProtocol string
+ if prot := r.Header.Values(protocolKey); len(prot) > 0 {
+ origProtocol = prot[0]
+ }
+
+ h.createWsProxy(origProtocol, upstreamProtocol).ServeHTTP(w, r)
+ }
+}
+
+func (h *Handler) handlePicoMediaProxy() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !h.gatewayAvailableForProxy() {
+ logger.Warnf("Gateway not available for Pico media proxy")
+ http.Error(w, "Gateway not available", http.StatusServiceUnavailable)
+ return
+ }
+
+ gateway.mu.Lock()
+ picoToken := gateway.picoToken
+ gateway.mu.Unlock()
+
+ if picoToken == "" {
+ logger.Warnf("Missing Pico token for media proxy")
+ http.Error(w, "Invalid Pico token", http.StatusForbidden)
+ return
+ }
+
+ h.createPicoHTTPProxy(picoToken).ServeHTTP(w, r)
+ }
+}
+
+// handleGetPicoInfo returns non-secret Pico connection info for the launcher UI.
//
-// GET /api/pico/token
-func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
+// GET /api/pico/info
+func (h *Handler) handleGetPicoInfo(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
- wsURL := h.buildWsURL(r)
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]any{
- "token": cfg.Channels.Pico.Token.String(),
- "ws_url": wsURL,
- "enabled": cfg.Channels.Pico.Enabled,
- })
+ h.writePicoInfoResponse(w, r, cfg, nil)
}
-// handleRegenPicoToken generates a new Pico WebSocket token and saves it.
+// handleRegenPicoToken rotates the raw Pico WebSocket token and returns
+// non-secret connection info for the launcher UI.
//
// POST /api/pico/token
func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
@@ -74,30 +219,30 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
}
token := generateSecureToken()
- cfg.Channels.Pico.SetToken(token)
+ if bc := cfg.Channels.GetByType(config.ChannelPico); bc != nil {
+ decoded, err := bc.GetDecoded()
+ if err == nil && decoded != nil {
+ if settings, ok := decoded.(*config.PicoSettings); ok {
+ settings.Token = *config.NewSecureString(token)
+ }
+ }
+ }
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
- wsURL := h.buildWsURL(r)
+ gateway.mu.Lock()
+ gateway.picoToken = token
+ gateway.mu.Unlock()
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]any{
- "token": token,
- "ws_url": wsURL,
- })
+ h.writePicoInfoResponse(w, r, cfg, nil)
}
// EnsurePicoChannel enables the Pico channel with sane defaults if it isn't
// already configured. Returns true when the config was modified.
-//
-// callerOrigin is the Origin header from the setup request. If non-empty and
-// no origins are configured yet, it's written as the allowed origin so the
-// WebSocket handshake works for whatever host the caller is on (LAN, custom
-// port, etc.). Pass "" when there's no request context.
-func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) {
+func (h *Handler) EnsurePicoChannel() (bool, error) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return false, fmt.Errorf("failed to load config: %w", err)
@@ -105,20 +250,24 @@ func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) {
changed := false
- if !cfg.Channels.Pico.Enabled {
- cfg.Channels.Pico.Enabled = true
+ bc := cfg.Channels.GetByType(config.ChannelPico)
+ if bc == nil {
+ bc = &config.Channel{Type: config.ChannelPico}
+ cfg.Channels["pico"] = bc
+ }
+
+ if !bc.Enabled {
+ bc.Enabled = true
changed = true
}
- if cfg.Channels.Pico.Token.String() == "" {
- cfg.Channels.Pico.SetToken(generateSecureToken())
- changed = true
- }
-
- // Seed origins from the request instead of hardcoding ports.
- if len(cfg.Channels.Pico.AllowOrigins) == 0 && callerOrigin != "" {
- cfg.Channels.Pico.AllowOrigins = []string{callerOrigin}
- changed = true
+ if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
+ if picoCfg, ok := decoded.(*config.PicoSettings); ok {
+ if picoCfg.Token.String() == "" {
+ picoCfg.Token = *config.NewSecureString(generateSecureToken())
+ changed = true
+ }
+ }
}
if changed {
@@ -134,27 +283,20 @@ func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) {
//
// POST /api/pico/setup
func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
- changed, err := h.EnsurePicoChannel(r.Header.Get("Origin"))
+ changed, err := h.EnsurePicoChannel()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
+ // Reload config (EnsurePicoChannel may have modified it).
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
- wsURL := h.buildWsURL(r)
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]any{
- "token": cfg.Channels.Pico.Token.String(),
- "ws_url": wsURL,
- "enabled": true,
- "changed": changed,
- })
+ h.writePicoInfoResponse(w, r, cfg, &changed)
}
// generateSecureToken creates a random 32-character hex string.
@@ -162,7 +304,7 @@ func generateSecureToken() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// Fallback to something pseudo-random if crypto/rand fails
- return fmt.Sprintf("pico_%x", time.Now().UnixNano())
+ return fmt.Sprintf("%032x", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go
index aa377975d..6f7cefd4d 100644
--- a/web/backend/api/pico_test.go
+++ b/web/backend/api/pico_test.go
@@ -9,16 +9,24 @@ import (
"os"
"path/filepath"
"strconv"
+ "strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
+ ppid "github.com/sipeed/picoclaw/pkg/pid"
)
+func newPicoProxyRequest(method, path string) *http.Request {
+ req := httptest.NewRequest(method, "http://launcher.local:18800"+path, nil)
+ req.Header.Set("Origin", "http://launcher.local:18800")
+ return req
+}
+
func TestEnsurePicoChannel_FreshConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
- changed, err := h.EnsurePicoChannel("")
+ changed, err := h.EnsurePicoChannel()
if err != nil {
t.Fatalf("EnsurePicoChannel() error = %v", err)
}
@@ -31,10 +39,16 @@ func TestEnsurePicoChannel_FreshConfig(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
- if !cfg.Channels.Pico.Enabled {
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ if !bc.Enabled {
t.Error("expected Pico to be enabled after setup")
}
- if cfg.Channels.Pico.Token.String() == "" {
+ if picoCfg.Token.String() == "" {
t.Error("expected a non-empty token after setup")
}
}
@@ -43,7 +57,7 @@ func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
- if _, err := h.EnsurePicoChannel(""); err != nil {
+ if _, err := h.EnsurePicoChannel(); err != nil {
t.Fatalf("EnsurePicoChannel() error = %v", err)
}
@@ -52,16 +66,22 @@ func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
- if cfg.Channels.Pico.AllowTokenQuery {
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ if picoCfg.AllowTokenQuery {
t.Error("setup must not enable allow_token_query by default")
}
}
-func TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) {
+func TestEnsurePicoChannel_LeavesAllowOriginsEmptyByDefault(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
- if _, err := h.EnsurePicoChannel("http://localhost:18800"); err != nil {
+ if _, err := h.EnsurePicoChannel(); err != nil {
t.Fatalf("EnsurePicoChannel() error = %v", err)
}
@@ -70,18 +90,22 @@ func TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
- for _, origin := range cfg.Channels.Pico.AllowOrigins {
- if origin == "*" {
- t.Error("setup must not set wildcard origin '*'")
- }
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ if len(picoCfg.AllowOrigins) != 0 {
+ t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins)
}
}
-func TestEnsurePicoChannel_NoOriginWithoutCaller(t *testing.T) {
+func TestEnsurePicoChannel_NoOriginConfigurationRequired(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
- if _, err := h.EnsurePicoChannel(""); err != nil {
+ if _, err := h.EnsurePicoChannel(); err != nil {
t.Fatalf("EnsurePicoChannel() error = %v", err)
}
@@ -90,29 +114,14 @@ func TestEnsurePicoChannel_NoOriginWithoutCaller(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
- // Without a caller origin, allow_origins stays empty (CheckOrigin
- // allows all when the list is empty, so the channel still works).
- if len(cfg.Channels.Pico.AllowOrigins) != 0 {
- t.Errorf("allow_origins = %v, want empty when no caller origin", cfg.Channels.Pico.AllowOrigins)
- }
-}
-
-func TestEnsurePicoChannel_SetsCallerOrigin(t *testing.T) {
- configPath := filepath.Join(t.TempDir(), "config.json")
- h := NewHandler(configPath)
-
- lanOrigin := "http://192.168.1.9:18800"
- if _, err := h.EnsurePicoChannel(lanOrigin); err != nil {
- t.Fatalf("EnsurePicoChannel() error = %v", err)
- }
-
- cfg, err := config.LoadConfig(configPath)
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
if err != nil {
- t.Fatalf("LoadConfig() error = %v", err)
+ t.Fatalf("GetDecoded() error = %v", err)
}
-
- if len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != lanOrigin {
- t.Errorf("allow_origins = %v, want [%s]", cfg.Channels.Pico.AllowOrigins, lanOrigin)
+ picoCfg := decoded.(*config.PicoSettings)
+ if len(picoCfg.AllowOrigins) != 0 {
+ t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins)
}
}
@@ -121,17 +130,23 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) {
// Pre-configure with custom user settings
cfg := config.DefaultConfig()
- cfg.Channels.Pico.Enabled = true
- cfg.Channels.Pico.SetToken("user-custom-token")
- cfg.Channels.Pico.AllowTokenQuery = true
- cfg.Channels.Pico.AllowOrigins = []string{"https://myapp.example.com"}
- if err := config.SaveConfig(configPath, cfg); err != nil {
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ bc.Enabled = true
+ picoCfg.SetToken("user-custom-token")
+ picoCfg.AllowTokenQuery = true
+ picoCfg.AllowOrigins = []string{"https://myapp.example.com"}
+ if err = config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
- changed, err := h.EnsurePicoChannel("")
+ changed, err := h.EnsurePicoChannel()
if err != nil {
t.Fatalf("EnsurePicoChannel() error = %v", err)
}
@@ -144,14 +159,20 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
- if cfg.Channels.Pico.Token.String() != "user-custom-token" {
- t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token.String(), "user-custom-token")
+ bc = cfg.Channels["pico"]
+ decoded, err = bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
}
- if !cfg.Channels.Pico.AllowTokenQuery {
+ picoCfg = decoded.(*config.PicoSettings)
+ if picoCfg.Token.String() != "user-custom-token" {
+ t.Errorf("token = %q, want %q", picoCfg.Token.String(), "user-custom-token")
+ }
+ if !picoCfg.AllowTokenQuery {
t.Error("user's allow_token_query=true must be preserved")
}
- if len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != "https://myapp.example.com" {
- t.Errorf("allow_origins = %v, want [https://myapp.example.com]", cfg.Channels.Pico.AllowOrigins)
+ if len(picoCfg.AllowOrigins) != 1 || picoCfg.AllowOrigins[0] != "https://myapp.example.com" {
+ t.Errorf("allow_origins = %v, want [https://myapp.example.com]", picoCfg.AllowOrigins)
}
}
@@ -169,7 +190,7 @@ func TestEnsurePicoChannel_ExistingConfigWithoutSecurityFile(t *testing.T) {
h := NewHandler(configPath)
- changed, err := h.EnsurePicoChannel("")
+ changed, err := h.EnsurePicoChannel()
if err != nil {
t.Fatalf("EnsurePicoChannel() error = %v", err)
}
@@ -182,10 +203,16 @@ func TestEnsurePicoChannel_ExistingConfigWithoutSecurityFile(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
- if !cfg.Channels.Pico.Enabled {
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ if !bc.Enabled {
t.Error("expected Pico to be enabled after setup")
}
- if cfg.Channels.Pico.Token.String() == "" {
+ if picoCfg.Token.String() == "" {
t.Error("expected a non-empty token after setup")
}
if _, err := os.Stat(filepath.Join(filepath.Dir(configPath), config.SecurityConfigFile)); err != nil {
@@ -203,7 +230,7 @@ func TestEnsurePicoChannel_ConfiguresPicoWithoutGateway(t *testing.T) {
}
h := NewHandler(configPath)
- if _, err := h.EnsurePicoChannel(""); err != nil {
+ if _, err := h.EnsurePicoChannel(); err != nil {
t.Fatalf("EnsurePicoChannel() error = %v", err)
}
@@ -212,10 +239,16 @@ func TestEnsurePicoChannel_ConfiguresPicoWithoutGateway(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
- if !cfg.Channels.Pico.Enabled {
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ if !bc.Enabled {
t.Error("expected Pico to be enabled after launcher startup setup")
}
- if cfg.Channels.Pico.Token.String() == "" {
+ if picoCfg.Token.String() == "" {
t.Error("expected a non-empty token after launcher startup setup")
}
}
@@ -224,18 +257,22 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
- origin := "http://localhost:18800"
-
// First call sets things up
- if _, err := h.EnsurePicoChannel(origin); err != nil {
+ if _, err := h.EnsurePicoChannel(); err != nil {
t.Fatalf("first EnsurePicoChannel() error = %v", err)
}
cfg1, _ := config.LoadConfig(configPath)
- token1 := cfg1.Channels.Pico.Token.String()
+ bc := cfg1.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ token1 := picoCfg.Token.String()
// Second call should be a no-op
- changed, err := h.EnsurePicoChannel(origin)
+ changed, err := h.EnsurePicoChannel()
if err != nil {
t.Fatalf("second EnsurePicoChannel() error = %v", err)
}
@@ -244,12 +281,18 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) {
}
cfg2, _ := config.LoadConfig(configPath)
- if cfg2.Channels.Pico.Token.String() != token1 {
+ bc = cfg2.Channels["pico"]
+ decoded, err = bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg = decoded.(*config.PicoSettings)
+ if picoCfg.Token.String() != token1 {
t.Error("token should not change on subsequent calls")
}
}
-func TestHandlePicoSetup_IncludesRequestOrigin(t *testing.T) {
+func TestHandlePicoSetup_DoesNotPersistRequestOrigin(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
@@ -268,8 +311,14 @@ func TestHandlePicoSetup_IncludesRequestOrigin(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
- if len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != "http://10.0.0.5:3000" {
- t.Errorf("allow_origins = %v, want [http://10.0.0.5:3000]", cfg.Channels.Pico.AllowOrigins)
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ if len(picoCfg.AllowOrigins) != 0 {
+ t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins)
}
}
@@ -291,8 +340,8 @@ func TestHandlePicoSetup_Response(t *testing.T) {
t.Fatalf("failed to decode response: %v", err)
}
- if resp["token"] == nil || resp["token"] == "" {
- t.Error("response should contain a non-empty token")
+ if _, ok := resp["token"]; ok {
+ t.Error("response must not expose the raw pico token")
}
if resp["ws_url"] == nil || resp["ws_url"] == "" {
t.Error("response should contain ws_url")
@@ -303,9 +352,107 @@ func TestHandlePicoSetup_Response(t *testing.T) {
if resp["changed"] != true {
t.Error("response should have changed=true on first setup")
}
+ if resp["configured"] != true {
+ t.Error("response should have configured=true")
+ }
+}
+
+func TestHandleGetPicoInfo_OmitsToken(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+
+ if _, err := h.EnsurePicoChannel(); err != nil {
+ t.Fatalf("EnsurePicoChannel() error = %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "http://launcher.local/api/pico/info", nil)
+ rec := httptest.NewRecorder()
+
+ h.handleGetPicoInfo(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ var resp map[string]any
+ if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if _, ok := resp["token"]; ok {
+ t.Fatal("info response must not expose the raw pico token")
+ }
+ if resp["enabled"] != true {
+ t.Fatalf("enabled = %#v, want true", resp["enabled"])
+ }
+ if resp["configured"] != true {
+ t.Fatalf("configured = %#v, want true", resp["configured"])
+ }
+ if resp["ws_url"] == nil || resp["ws_url"] == "" {
+ t.Fatal("response should contain ws_url")
+ }
+}
+
+func TestHandleRegenPicoToken_RefreshesGatewayTokenCache(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+
+ if _, err := h.EnsurePicoChannel(); err != nil {
+ t.Fatalf("EnsurePicoChannel() error = %v", err)
+ }
+
+ origPicoToken := gateway.picoToken
+ t.Cleanup(func() {
+ gateway.mu.Lock()
+ gateway.picoToken = origPicoToken
+ gateway.mu.Unlock()
+ })
+
+ gateway.mu.Lock()
+ gateway.picoToken = "stale-token"
+ gateway.mu.Unlock()
+
+ req := httptest.NewRequest(http.MethodPost, "http://launcher.local/api/pico/token", nil)
+ rec := httptest.NewRecorder()
+ h.handleRegenPicoToken(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ token := decoded.(*config.PicoSettings).Token.String()
+ if token == "" {
+ t.Fatal("expected regenerated pico token to be persisted")
+ }
+ if token == "stale-token" {
+ t.Fatal("expected regenerated pico token to differ from stale cache")
+ }
+
+ gateway.mu.Lock()
+ defer gateway.mu.Unlock()
+ if gateway.picoToken != token {
+ t.Fatalf("gateway.picoToken = %q, want %q", gateway.picoToken, token)
+ }
}
func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) {
+ origMatcher := gatewayProcessMatcher
+ gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
+ t.Cleanup(func() { gatewayProcessMatcher = origMatcher })
+
+ home := t.TempDir()
+ t.Setenv("PICOCLAW_HOME", home)
+
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
handler := h.handleWebSocketProxy()
@@ -334,8 +481,30 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) {
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+ writeTestPidFile(t, ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "test-token",
+ Host: cfg.Gateway.Host,
+ Port: cfg.Gateway.Port,
+ })
+ origPidData := gateway.pidData
+ origPicoToken := gateway.picoToken
+ t.Cleanup(func() {
+ ppid.RemovePidFile(globalConfigDir())
+ gateway.pidData = origPidData
+ gateway.picoToken = origPicoToken
+ })
- req1 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil)
+ gateway.pidData = &ppid.PidFileData{}
+ gateway.picoToken = "pico"
+ req1 := newPicoProxyRequest(http.MethodGet, "/pico/ws")
rec1 := httptest.NewRecorder()
handler(rec1, req1)
@@ -351,7 +520,7 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) {
t.Fatalf("SaveConfig() error = %v", err)
}
- req2 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil)
+ req2 := newPicoProxyRequest(http.MethodGet, "/pico/ws")
rec2 := httptest.NewRecorder()
handler(rec2, req2)
@@ -363,6 +532,428 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) {
}
}
+func TestHandleWebSocketProxyLoadsCachedPicoTokenWhenMissing(t *testing.T) {
+ origMatcher := gatewayProcessMatcher
+ gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
+ t.Cleanup(func() { gatewayProcessMatcher = origMatcher })
+
+ home := t.TempDir()
+ t.Setenv("PICOCLAW_HOME", home)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ handler := h.handleWebSocketProxy()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/pico/ws" {
+ t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/ws")
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = io.WriteString(w, "proxied")
+ }))
+ defer server.Close()
+
+ cfg := config.DefaultConfig()
+ cfg.Gateway.Host = "127.0.0.1"
+ cfg.Gateway.Port = mustGatewayTestPort(t, server.URL)
+ bc := cfg.Channels["pico"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ picoCfg := decoded.(*config.PicoSettings)
+ bc.Enabled = true
+ picoCfg.SetToken("cached-token")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+ writeTestPidFile(t, ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "test-token",
+ Host: cfg.Gateway.Host,
+ Port: cfg.Gateway.Port,
+ })
+ t.Cleanup(func() {
+ ppid.RemovePidFile(globalConfigDir())
+ })
+
+ origPidData := gateway.pidData
+ origPicoToken := gateway.picoToken
+ t.Cleanup(func() {
+ gateway.pidData = origPidData
+ gateway.picoToken = origPicoToken
+ })
+
+ gateway.pidData = &ppid.PidFileData{}
+ gateway.picoToken = ""
+
+ req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session")
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+ if body := rec.Body.String(); body != "proxied" {
+ t.Fatalf("body = %q, want %q", body, "proxied")
+ }
+ if gateway.picoToken != "cached-token" {
+ t.Fatalf("gateway.picoToken = %q, want %q", gateway.picoToken, "cached-token")
+ }
+}
+
+func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) {
+ origMatcher := gatewayProcessMatcher
+ gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
+ t.Cleanup(func() { gatewayProcessMatcher = origMatcher })
+
+ home := t.TempDir()
+ t.Setenv("PICOCLAW_HOME", home)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ handler := h.handleWebSocketProxy()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/pico/ws" {
+ t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/ws")
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = io.WriteString(w, r.Header.Get(protocolKey))
+ }))
+ defer server.Close()
+
+ cfg := config.DefaultConfig()
+ cfg.Gateway.Host = "127.0.0.1"
+ cfg.Gateway.Port = mustGatewayTestPort(t, server.URL)
+ bc := cfg.Channels["pico"]
+ bc.Enabled = true
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ decoded.(*config.PicoSettings).SetToken("ui-token")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+ pidData := ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "test-token",
+ Host: cfg.Gateway.Host,
+ Port: cfg.Gateway.Port,
+ }
+ writeTestPidFile(t, pidData)
+ t.Cleanup(func() {
+ ppid.RemovePidFile(globalConfigDir())
+ })
+
+ origPidData := gateway.pidData
+ origPicoToken := gateway.picoToken
+ origStatus := gateway.runtimeStatus
+ t.Cleanup(func() {
+ gateway.mu.Lock()
+ gateway.pidData = origPidData
+ gateway.picoToken = origPicoToken
+ gateway.runtimeStatus = origStatus
+ gateway.mu.Unlock()
+ })
+
+ gateway.mu.Lock()
+ gateway.pidData = nil
+ gateway.picoToken = ""
+ setGatewayRuntimeStatusLocked("stopped")
+ gateway.mu.Unlock()
+
+ req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session")
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ expected := tokenPrefix + "ui-token"
+ if got := rec.Body.String(); got != expected {
+ t.Fatalf("forwarded protocol = %q, want %q", got, expected)
+ }
+
+ gateway.mu.Lock()
+ defer gateway.mu.Unlock()
+ if gateway.pidData == nil {
+ t.Fatal("gateway.pidData should be loaded from pid file")
+ }
+ if gateway.runtimeStatus != "running" {
+ t.Fatalf("runtimeStatus = %q, want %q", gateway.runtimeStatus, "running")
+ }
+}
+
+func TestCreatePicoHTTPProxyInjectsGatewayAuth(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+
+ cfg := config.DefaultConfig()
+ cfg.Gateway.Host = "127.0.0.1"
+ cfg.Gateway.Port = 18790
+ bc := cfg.Channels["pico"]
+ bc.Enabled = true
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ decoded.(*config.PicoSettings).SetToken("ui-token")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ proxy := h.createPicoHTTPProxy("ui-token")
+ var capturedPath string
+ var capturedAuth string
+ proxy.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ capturedPath = req.URL.Path
+ capturedAuth = req.Header.Get("Authorization")
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader("proxied")),
+ Request: req,
+ }, nil
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/pico/media/attachment-1", nil)
+ rec := httptest.NewRecorder()
+ proxy.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+ if capturedPath != "/pico/media/attachment-1" {
+ t.Fatalf("capturedPath = %q, want %q", capturedPath, "/pico/media/attachment-1")
+ }
+ expected := "Bearer ui-token"
+ if capturedAuth != expected {
+ t.Fatalf("Authorization = %q, want %q", capturedAuth, expected)
+ }
+}
+
+func TestHandlePicoMediaProxyUsesRawBearerToken(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("PICOCLAW_HOME", home)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ handler := h.handlePicoMediaProxy()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/pico/media/attachment-1" {
+ t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/media/attachment-1")
+ }
+ if got := r.Header.Get("Authorization"); got != "Bearer ui-token" {
+ t.Fatalf("Authorization = %q, want %q", got, "Bearer ui-token")
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = io.WriteString(w, "proxied-media")
+ }))
+ defer server.Close()
+
+ cfg := config.DefaultConfig()
+ cfg.Gateway.Host = "127.0.0.1"
+ cfg.Gateway.Port = mustGatewayTestPort(t, server.URL)
+ bc := cfg.Channels["pico"]
+ bc.Enabled = true
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ decoded.(*config.PicoSettings).SetToken("ui-token")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+
+ origPidData := gateway.pidData
+ origPicoToken := gateway.picoToken
+ origCmd := gateway.cmd
+ t.Cleanup(func() {
+ gateway.mu.Lock()
+ gateway.pidData = origPidData
+ gateway.picoToken = origPicoToken
+ gateway.cmd = origCmd
+ gateway.mu.Unlock()
+ })
+
+ gateway.mu.Lock()
+ gateway.pidData = &ppid.PidFileData{PID: cmd.Process.Pid}
+ gateway.picoToken = "ui-token"
+ gateway.cmd = cmd
+ gateway.mu.Unlock()
+
+ req := newPicoProxyRequest(http.MethodGet, "/pico/media/attachment-1")
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+ if body := rec.Body.String(); body != "proxied-media" {
+ t.Fatalf("body = %q, want %q", body, "proxied-media")
+ }
+}
+
+func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Setenv("HOME", tmpDir)
+ t.Setenv("PICOCLAW_HOME", filepath.Join(tmpDir, ".picoclaw"))
+
+ configPath := filepath.Join(tmpDir, "config.json")
+ h := NewHandler(configPath)
+ handler := h.handleWebSocketProxy()
+
+ cfg := config.DefaultConfig()
+ bc := cfg.Channels["pico"]
+ bc.Enabled = true
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ decoded.(*config.PicoSettings).SetToken("ui-token")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ cmd := startLongRunningProcess(t)
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+
+ origPidData := gateway.pidData
+ origPicoToken := gateway.picoToken
+ origCmd := gateway.cmd
+ origStatus := gateway.runtimeStatus
+ t.Cleanup(func() {
+ gateway.mu.Lock()
+ gateway.pidData = origPidData
+ gateway.picoToken = origPicoToken
+ gateway.cmd = origCmd
+ gateway.runtimeStatus = origStatus
+ gateway.mu.Unlock()
+ })
+
+ gateway.mu.Lock()
+ gateway.pidData = &ppid.PidFileData{PID: cmd.Process.Pid, Token: "stale-token"}
+ gateway.picoToken = "ui-token"
+ gateway.cmd = cmd
+ setGatewayRuntimeStatusLocked("running")
+ gateway.mu.Unlock()
+
+ req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session")
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
+ }
+ gateway.mu.Lock()
+ defer gateway.mu.Unlock()
+ if gateway.pidData != nil {
+ t.Fatal("gateway.pidData should be cleared after stale process exit is detected")
+ }
+}
+
+func TestHandleWebSocketProxy_AllowsArbitraryOrigin(t *testing.T) {
+ origMatcher := gatewayProcessMatcher
+ gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
+ t.Cleanup(func() { gatewayProcessMatcher = origMatcher })
+
+ home := t.TempDir()
+ t.Setenv("PICOCLAW_HOME", home)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ handler := h.handleWebSocketProxy()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/pico/ws" {
+ t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/ws")
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = io.WriteString(w, "proxied")
+ }))
+ defer server.Close()
+
+ cfg := config.DefaultConfig()
+ cfg.Gateway.Host = "127.0.0.1"
+ cfg.Gateway.Port = mustGatewayTestPort(t, server.URL)
+ bc := cfg.Channels["pico"]
+ bc.Enabled = true
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ decoded.(*config.PicoSettings).SetToken("ui-token")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+ writeTestPidFile(t, ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "test-token",
+ Host: cfg.Gateway.Host,
+ Port: cfg.Gateway.Port,
+ })
+ t.Cleanup(func() {
+ ppid.RemovePidFile(globalConfigDir())
+ })
+
+ origPidData := gateway.pidData
+ origPicoToken := gateway.picoToken
+ t.Cleanup(func() {
+ gateway.pidData = origPidData
+ gateway.picoToken = origPicoToken
+ })
+
+ gateway.pidData = &ppid.PidFileData{}
+ gateway.picoToken = "ui-token"
+
+ req := httptest.NewRequest(http.MethodGet, "http://launcher.local/pico/ws?session_id=test-session", nil)
+ req.Header.Set("Origin", "http://evil.example")
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+}
+
func mustGatewayTestPort(t *testing.T, rawURL string) int {
t.Helper()
@@ -378,3 +969,9 @@ func mustGatewayTestPort(t *testing.T, rawURL string) int {
return port
}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return fn(req)
+}
diff --git a/web/backend/api/router.go b/web/backend/api/router.go
index ce652d4c4..76f63607e 100644
--- a/web/backend/api/router.go
+++ b/web/backend/api/router.go
@@ -2,6 +2,7 @@ package api
import (
"net/http"
+ "strings"
"sync"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
@@ -13,7 +14,10 @@ type Handler struct {
serverPort int
serverPublic bool
serverPublicExplicit bool
+ serverHostInput string
+ serverHostExplicit bool
serverCIDRs []string
+ debug bool
oauthMu sync.Mutex
oauthFlows map[string]*oauthFlow
oauthState map[string]string
@@ -40,9 +44,25 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a
h.serverPort = port
h.serverPublic = public
h.serverPublicExplicit = publicExplicit
+ h.serverHostInput = ""
+ h.serverHostExplicit = false
h.serverCIDRs = append([]string(nil), allowedCIDRs...)
}
+// SetServerBindHost stores the launcher's effective bind host.
+// When explicit is true, hostInput is the normalized -host / PICOCLAW_LAUNCHER_HOST value.
+func (h *Handler) SetServerBindHost(hostInput string, explicit bool) {
+ h.serverHostInput = strings.TrimSpace(hostInput)
+ if !explicit {
+ h.serverHostInput = ""
+ }
+ h.serverHostExplicit = explicit
+}
+
+func (h *Handler) SetDebug(debug bool) {
+ h.debug = debug
+}
+
// RegisterRoutes binds all API endpoint handlers to the ServeMux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Config CRUD
@@ -76,6 +96,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Launcher service parameters (port/public)
h.registerLauncherConfigRoutes(mux)
+ // Self-update endpoint (requires dashboard auth)
+ h.registerUpdateRoutes(mux)
+
+ // Runtime build/version metadata
+ h.registerVersionRoutes(mux)
+
// WeChat QR login flow
h.registerWeixinRoutes(mux)
diff --git a/web/backend/api/session.go b/web/backend/api/session.go
index 42d451a05..cc18ee6e1 100644
--- a/web/backend/api/session.go
+++ b/web/backend/api/session.go
@@ -13,7 +13,11 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/providers/messageutil"
+ "github.com/sipeed/picoclaw/pkg/session"
+ "github.com/sipeed/picoclaw/pkg/utils"
)
// registerSessionRoutes binds session list and detail endpoints to the ServeMux.
@@ -42,52 +46,58 @@ type sessionListItem struct {
Updated string `json:"updated"`
}
-type sessionMetaFile struct {
- Key string `json:"key"`
- Summary string `json:"summary"`
- Skip int `json:"skip"`
- Count int `json:"count"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+type sessionChatMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ Kind string `json:"kind,omitempty"`
+ Media []string `json:"media,omitempty"`
+ Attachments []sessionChatAttachment `json:"attachments,omitempty"`
+ ToolCalls []utils.VisibleToolCall `json:"tool_calls,omitempty"`
}
-// picoSessionPrefix is the key prefix used by the gateway's routing for Pico
-// channel sessions. The full key format is:
-//
-// agent:main:pico:direct:pico:
-//
-// The sanitized filename replaces ':' with '_', so on disk it becomes:
-//
-// agent_main_pico_direct_pico_.json
+type sessionChatAttachment struct {
+ Type string `json:"type,omitempty"`
+ URL string `json:"url,omitempty"`
+ Filename string `json:"filename,omitempty"`
+ ContentType string `json:"content_type,omitempty"`
+}
+
+// legacyPicoSessionPrefix is the legacy key prefix used by older Pico JSON/JSONL
+// sessions before structured scope metadata existed.
const (
- picoSessionPrefix = "agent:main:pico:direct:pico:"
- sanitizedPicoSessionPrefix = "agent_main_pico_direct_pico_"
- maxSessionJSONLLineSize = 10 * 1024 * 1024 // 10 MB
- maxSessionTitleRunes = 60
+ legacyPicoSessionPrefix = "agent:main:pico:direct:pico:"
+ picoSessionPrefix = legacyPicoSessionPrefix
+
+ // Keep the session API aligned with the shared JSONL store reader limit in
+ // pkg/memory/jsonl.go so oversized lines fail consistently everywhere.
+ maxSessionJSONLLineSize = 10 * 1024 * 1024
+ maxSessionTitleRunes = 60
+
+ handledToolResponseSummaryText = "Requested output delivered via tool attachment."
)
-// extractPicoSessionID extracts the session UUID from a full session key.
-// Returns the UUID and true if the key matches the Pico session pattern.
-func extractPicoSessionID(key string) (string, bool) {
- if strings.HasPrefix(key, picoSessionPrefix) {
- return strings.TrimPrefix(key, picoSessionPrefix), true
- }
- return "", false
+func defaultToolFeedbackMaxArgsLength() int {
+ defaults := config.AgentDefaults{}
+ return defaults.GetToolFeedbackMaxArgsLength()
}
-func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) {
- if strings.HasPrefix(key, sanitizedPicoSessionPrefix) {
- return strings.TrimPrefix(key, sanitizedPicoSessionPrefix), true
+// extractLegacyPicoSessionID extracts the session UUID from an old Pico key.
+// Returns the UUID and true if the key matches the Pico session pattern.
+func extractLegacyPicoSessionID(key string) (string, bool) {
+ if strings.HasPrefix(key, legacyPicoSessionPrefix) {
+ return strings.TrimPrefix(key, legacyPicoSessionPrefix), true
}
return "", false
}
func sanitizeSessionKey(key string) string {
- return strings.ReplaceAll(key, ":", "_")
+ key = strings.ReplaceAll(key, ":", "_")
+ key = strings.ReplaceAll(key, "/", "_")
+ key = strings.ReplaceAll(key, "\\", "_")
+ return key
}
-func (h *Handler) readLegacySession(dir, sessionID string) (sessionFile, error) {
- path := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)+".json")
+func (h *Handler) readLegacySession(path string) (sessionFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return sessionFile{}, err
@@ -100,18 +110,18 @@ func (h *Handler) readLegacySession(dir, sessionID string) (sessionFile, error)
return sess, nil
}
-func (h *Handler) readSessionMeta(path, sessionKey string) (sessionMetaFile, error) {
+func (h *Handler) readSessionMeta(path, sessionKey string) (memory.SessionMeta, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
- return sessionMetaFile{Key: sessionKey}, nil
+ return memory.SessionMeta{Key: sessionKey}, nil
}
if err != nil {
- return sessionMetaFile{}, err
+ return memory.SessionMeta{}, err
}
- var meta sessionMetaFile
+ var meta memory.SessionMeta
if err := json.Unmarshal(data, &meta); err != nil {
- return sessionMetaFile{}, err
+ return memory.SessionMeta{}, err
}
if meta.Key == "" {
meta.Key = sessionKey
@@ -146,6 +156,9 @@ func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Messag
if err := json.Unmarshal(line, &msg); err != nil {
continue
}
+ if messageutil.IsTransientAssistantThoughtMessage(msg) {
+ continue
+ }
msgs = append(msgs, msg)
}
if err := scanner.Err(); err != nil {
@@ -154,8 +167,7 @@ func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Messag
return msgs, nil
}
-func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {
- sessionKey := picoSessionPrefix + sessionID
+func (h *Handler) readJSONLSession(dir, sessionKey string) (sessionFile, error) {
base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
jsonlPath := base + ".jsonl"
metaPath := base + ".meta.json"
@@ -192,41 +204,237 @@ func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {
}, nil
}
-func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem {
+type picoJSONLSessionRef struct {
+ ID string
+ Key string
+}
+
+type picoLegacySessionRef struct {
+ ID string
+ Path string
+}
+
+func extractPicoSessionIDFromScope(scope session.SessionScope) (string, bool) {
+ if !strings.EqualFold(strings.TrimSpace(scope.Channel), "pico") {
+ return "", false
+ }
+
+ candidates := []string{
+ strings.TrimSpace(scope.Values["sender"]),
+ strings.TrimSpace(scope.Values["chat"]),
+ }
+ for _, candidate := range candidates {
+ if candidate == "" {
+ continue
+ }
+ if idx := strings.Index(candidate, "pico:"); idx >= 0 {
+ sessionID := strings.TrimSpace(candidate[idx+len("pico:"):])
+ if sessionID != "" {
+ return sessionID, true
+ }
+ }
+ }
+ return "", false
+}
+
+func sessionRefFromMeta(meta memory.SessionMeta) (picoJSONLSessionRef, bool) {
+ if len(meta.Scope) == 0 {
+ if sessionID, ok := extractLegacyPicoSessionID(meta.Key); ok {
+ return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true
+ }
+ for _, alias := range meta.Aliases {
+ if sessionID, ok := extractLegacyPicoSessionID(alias); ok {
+ return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true
+ }
+ }
+ return picoJSONLSessionRef{}, false
+ }
+ var scope session.SessionScope
+ if err := json.Unmarshal(meta.Scope, &scope); err != nil {
+ return picoJSONLSessionRef{}, false
+ }
+ sessionID, ok := extractPicoSessionIDFromScope(scope)
+ if !ok {
+ if legacySessionID, ok := extractLegacyPicoSessionID(meta.Key); ok {
+ return picoJSONLSessionRef{ID: legacySessionID, Key: meta.Key}, true
+ }
+ for _, alias := range meta.Aliases {
+ if legacySessionID, ok := extractLegacyPicoSessionID(alias); ok {
+ return picoJSONLSessionRef{ID: legacySessionID, Key: meta.Key}, true
+ }
+ }
+ return picoJSONLSessionRef{}, false
+ }
+ return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true
+}
+
+func (h *Handler) findPicoJSONLSessions(dir string) ([]picoJSONLSessionRef, error) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+
+ refs := make([]picoJSONLSessionRef, 0)
+ seen := make(map[string]struct{})
+ metaBackedBases := make(map[string]struct{})
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta.json") {
+ continue
+ }
+ name := entry.Name()
+ metaPath := filepath.Join(dir, name)
+ meta, err := h.readSessionMeta(metaPath, "")
+ if err != nil {
+ continue
+ }
+ ref, ok := sessionRefFromMeta(meta)
+ if !ok || ref.Key == "" || ref.ID == "" {
+ continue
+ }
+ metaBackedBases[strings.TrimSuffix(name, ".meta.json")] = struct{}{}
+ if _, exists := seen[ref.ID]; exists {
+ continue
+ }
+ seen[ref.ID] = struct{}{}
+ refs = append(refs, ref)
+ }
+
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
+ continue
+ }
+ name := entry.Name()
+ base := strings.TrimSuffix(name, ".jsonl")
+ if _, ok := metaBackedBases[base]; ok {
+ continue
+ }
+ ref, ok := jsonlSessionRefFromFilename(name)
+ if !ok || ref.Key == "" || ref.ID == "" {
+ continue
+ }
+ if _, exists := seen[ref.ID]; exists {
+ continue
+ }
+ seen[ref.ID] = struct{}{}
+ refs = append(refs, ref)
+ }
+ return refs, nil
+}
+
+func (h *Handler) findPicoJSONLSession(dir, sessionID string) (picoJSONLSessionRef, error) {
+ refs, err := h.findPicoJSONLSessions(dir)
+ if err != nil {
+ return picoJSONLSessionRef{}, err
+ }
+ for _, ref := range refs {
+ if ref.ID == sessionID {
+ return ref, nil
+ }
+ }
+ return picoJSONLSessionRef{}, os.ErrNotExist
+}
+
+func (h *Handler) findLegacyPicoSessions(dir string) ([]picoLegacySessionRef, error) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+
+ refs := make([]picoLegacySessionRef, 0)
+ seen := make(map[string]struct{})
+ for _, entry := range entries {
+ name := entry.Name()
+ if entry.IsDir() || filepath.Ext(name) != ".json" || strings.HasSuffix(name, ".meta.json") {
+ continue
+ }
+
+ path := filepath.Join(dir, entry.Name())
+ sess, err := h.readLegacySession(path)
+ if err != nil || isEmptySession(sess) {
+ continue
+ }
+
+ sessionID, ok := extractLegacyPicoSessionID(sess.Key)
+ if !ok || sessionID == "" {
+ continue
+ }
+ if _, exists := seen[sessionID]; exists {
+ continue
+ }
+ seen[sessionID] = struct{}{}
+ refs = append(refs, picoLegacySessionRef{ID: sessionID, Path: path})
+ }
+ return refs, nil
+}
+
+func jsonlSessionRefFromFilename(name string) (picoJSONLSessionRef, bool) {
+ if !strings.HasSuffix(name, ".jsonl") {
+ return picoJSONLSessionRef{}, false
+ }
+ base := strings.TrimSuffix(name, ".jsonl")
+ if base == "" {
+ return picoJSONLSessionRef{}, false
+ }
+
+ legacyPrefix := sanitizeSessionKey(legacyPicoSessionPrefix)
+ if strings.HasPrefix(base, legacyPrefix) {
+ sessionID := strings.TrimPrefix(base, legacyPrefix)
+ if sessionID == "" {
+ return picoJSONLSessionRef{}, false
+ }
+ return picoJSONLSessionRef{
+ ID: sessionID,
+ Key: legacyPicoSessionPrefix + sessionID,
+ }, true
+ }
+
+ if session.IsOpaqueSessionKey(base) {
+ return picoJSONLSessionRef{
+ ID: base,
+ Key: base,
+ }, true
+ }
+
+ return picoJSONLSessionRef{}, false
+}
+
+func (h *Handler) findLegacyPicoSession(dir, sessionID string) (picoLegacySessionRef, error) {
+ refs, err := h.findLegacyPicoSessions(dir)
+ if err != nil {
+ return picoLegacySessionRef{}, err
+ }
+ for _, ref := range refs {
+ if ref.ID == sessionID {
+ return ref, nil
+ }
+ }
+ return picoLegacySessionRef{}, os.ErrNotExist
+}
+
+func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArgsLength int) sessionListItem {
+ transcript := visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)
+
preview := ""
- for _, msg := range sess.Messages {
- if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
- preview = msg.Content
+ for _, msg := range transcript {
+ if msg.Role == "user" {
+ preview = sessionChatMessagePreview(msg)
+ }
+ if preview != "" {
break
}
}
- title := strings.TrimSpace(sess.Summary)
- if title == "" {
- title = preview
- }
-
- title = truncateRunes(title, maxSessionTitleRunes)
preview = truncateRunes(preview, maxSessionTitleRunes)
if preview == "" {
preview = "(empty)"
}
- if title == "" {
- title = preview
- }
-
- validMessageCount := 0
- for _, msg := range sess.Messages {
- if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
- validMessageCount++
- }
- }
+ title := preview
return sessionListItem{
ID: sessionID,
Title: title,
Preview: preview,
- MessageCount: validMessageCount,
+ MessageCount: len(transcript),
Created: sess.Created.Format(time.RFC3339),
Updated: sess.Updated.Format(time.RFC3339),
}
@@ -247,6 +455,306 @@ func truncateRunes(s string, maxLen int) string {
return string(runes[:maxLen]) + "..."
}
+func sessionChatMessageVisible(msg sessionChatMessage) bool {
+ return strings.TrimSpace(msg.Content) != "" ||
+ len(msg.Media) > 0 ||
+ len(msg.Attachments) > 0 ||
+ len(msg.ToolCalls) > 0
+}
+
+func sessionChatMessagePreview(msg sessionChatMessage) string {
+ if content := strings.TrimSpace(msg.Content); content != "" {
+ return content
+ }
+ if len(msg.Attachments) > 0 {
+ if strings.EqualFold(strings.TrimSpace(msg.Attachments[0].Type), "image") {
+ return "[image]"
+ }
+ return "[attachment]"
+ }
+ if len(msg.Media) > 0 {
+ if strings.HasPrefix(strings.TrimSpace(msg.Media[0]), "data:image/") {
+ return "[image]"
+ }
+ return "[attachment]"
+ }
+ if len(msg.ToolCalls) > 0 {
+ return "[tool call]"
+ }
+ return ""
+}
+
+func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage {
+ return sessionTranscriptMessages(messages, toolFeedbackMaxArgsLength, false)
+}
+
+func detailSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage {
+ return sessionTranscriptMessages(messages, toolFeedbackMaxArgsLength, true)
+}
+
+func sessionTranscriptMessages(
+ messages []providers.Message,
+ toolFeedbackMaxArgsLength int,
+ includeThoughts bool,
+) []sessionChatMessage {
+ transcript := make([]sessionChatMessage, 0, len(messages))
+
+ for _, msg := range messages {
+ attachments := sessionAttachments(msg)
+
+ switch msg.Role {
+ case "tool":
+ continue
+
+ case "user":
+ chatMsg := sessionChatMessage{
+ Role: "user",
+ Content: msg.Content,
+ Media: append([]string(nil), msg.Media...),
+ Attachments: attachments,
+ }
+ if sessionChatMessageVisible(chatMsg) {
+ transcript = append(transcript, chatMsg)
+ }
+
+ case "assistant":
+ if messageutil.IsTransientAssistantThoughtMessage(msg) {
+ continue
+ }
+ if includeThoughts {
+ if thoughtMsg, ok := assistantThoughtMessage(msg); ok {
+ transcript = append(transcript, thoughtMsg)
+ }
+ }
+
+ toolCallsMsg, hasToolCallsMsg := assistantToolCallsMessage(
+ msg.ToolCalls,
+ toolFeedbackMaxArgsLength,
+ )
+ visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls)
+
+ // Pico web chat can persist both visible `message` tool output and a
+ // later plain assistant reply in the same turn. Hide only the fixed
+ // internal summary that marks handled tool delivery.
+ content := msg.Content
+ if assistantMessageInternalOnly(msg) {
+ if len(attachments) == 0 {
+ if hasToolCallsMsg {
+ transcript = append(transcript, toolCallsMsg)
+ }
+ if len(visibleToolMessages) > 0 {
+ transcript = append(transcript, visibleToolMessages...)
+ }
+ continue
+ }
+ content = ""
+ }
+ if hasToolCallsMsg && utils.ToolCallExplanationDuplicatesContent(content, msg.ToolCalls) {
+ content = ""
+ }
+
+ chatMsg := sessionChatMessage{
+ Role: "assistant",
+ Content: content,
+ Media: append([]string(nil), msg.Media...),
+ Attachments: attachments,
+ }
+ if !sessionChatMessageVisible(chatMsg) {
+ if hasToolCallsMsg {
+ transcript = append(transcript, toolCallsMsg)
+ }
+ if len(visibleToolMessages) > 0 {
+ transcript = append(transcript, visibleToolMessages...)
+ }
+ continue
+ }
+
+ transcript = append(transcript, chatMsg)
+ if hasToolCallsMsg {
+ transcript = append(transcript, toolCallsMsg)
+ }
+ if len(visibleToolMessages) > 0 {
+ transcript = append(transcript, visibleToolMessages...)
+ }
+ }
+ }
+
+ return filterSessionChatMessages(transcript)
+}
+
+func filterSessionChatMessages(messages []sessionChatMessage) []sessionChatMessage {
+ filtered := messages[:0]
+ for _, msg := range messages {
+ if msg.Role != "user" && msg.Role != "assistant" {
+ continue
+ }
+ filtered = append(filtered, msg)
+ }
+ return filtered
+}
+
+func sessionAttachments(msg providers.Message) []sessionChatAttachment {
+ if len(msg.Attachments) == 0 {
+ return nil
+ }
+
+ attachments := make([]sessionChatAttachment, 0, len(msg.Attachments))
+ for _, attachment := range msg.Attachments {
+ urlValue, ok := sessionAttachmentURL(attachment)
+ if !ok {
+ continue
+ }
+ attachmentType := strings.TrimSpace(attachment.Type)
+ if attachmentType == "" {
+ attachmentType = sessionAttachmentType(attachment)
+ }
+ attachments = append(attachments, sessionChatAttachment{
+ Type: attachmentType,
+ URL: urlValue,
+ Filename: strings.TrimSpace(attachment.Filename),
+ ContentType: strings.TrimSpace(attachment.ContentType),
+ })
+ }
+
+ if len(attachments) == 0 {
+ return nil
+ }
+ return attachments
+}
+
+func sessionAttachmentURL(attachment providers.Attachment) (string, bool) {
+ if rawURL := strings.TrimSpace(attachment.URL); rawURL != "" {
+ return rawURL, true
+ }
+
+ ref := strings.TrimSpace(attachment.Ref)
+ if ref == "" {
+ return "", false
+ }
+ if strings.HasPrefix(ref, "media://") {
+ // Persisted session history must only expose durable attachment locations.
+ // media:// refs depend on the live in-memory MediaStore and may stop
+ // resolving after a restart or cleanup, so omit them from reopened history.
+ return "", false
+ }
+ return ref, true
+}
+
+func sessionAttachmentType(attachment providers.Attachment) string {
+ contentType := strings.ToLower(strings.TrimSpace(attachment.ContentType))
+ filename := strings.ToLower(strings.TrimSpace(attachment.Filename))
+ rawRef := strings.ToLower(strings.TrimSpace(attachment.Ref))
+ rawURL := strings.ToLower(strings.TrimSpace(attachment.URL))
+
+ switch {
+ case strings.HasPrefix(contentType, "image/"),
+ strings.HasPrefix(rawRef, "data:image/"),
+ strings.HasPrefix(rawURL, "data:image/"):
+ return "image"
+ case strings.HasPrefix(contentType, "audio/"):
+ return "audio"
+ case strings.HasPrefix(contentType, "video/"):
+ return "video"
+ }
+
+ switch ext := filepath.Ext(filename); ext {
+ case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
+ return "image"
+ case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
+ return "audio"
+ case ".mp4", ".avi", ".mov", ".webm", ".mkv":
+ return "video"
+ default:
+ return "file"
+ }
+}
+
+func assistantMessageInternalOnly(msg providers.Message) bool {
+ return strings.TrimSpace(msg.Content) == handledToolResponseSummaryText
+}
+
+func assistantThoughtMessage(msg providers.Message) (sessionChatMessage, bool) {
+ reasoning := strings.TrimSpace(msg.ReasoningContent)
+ if reasoning == "" {
+ return sessionChatMessage{}, false
+ }
+ if reasoning == strings.TrimSpace(msg.Content) {
+ return sessionChatMessage{}, false
+ }
+ return sessionChatMessage{
+ Role: "assistant",
+ Content: reasoning,
+ Kind: "thought",
+ }, true
+}
+
+func assistantToolCallsMessage(
+ toolCalls []providers.ToolCall,
+ toolFeedbackMaxArgsLength int,
+) (sessionChatMessage, bool) {
+ if len(toolCalls) == 0 {
+ return sessionChatMessage{}, false
+ }
+ if toolFeedbackMaxArgsLength <= 0 {
+ toolFeedbackMaxArgsLength = defaultToolFeedbackMaxArgsLength()
+ }
+
+ visibleToolCalls := utils.BuildVisibleToolCalls(toolCalls, toolFeedbackMaxArgsLength)
+ if len(visibleToolCalls) == 0 {
+ return sessionChatMessage{}, false
+ }
+
+ return sessionChatMessage{
+ Role: "assistant",
+ Kind: "tool_calls",
+ ToolCalls: visibleToolCalls,
+ }, true
+}
+
+func visibleAssistantToolArgsPreview(
+ tc providers.ToolCall,
+ toolFeedbackMaxArgsLength int,
+) string {
+ return utils.VisibleToolCallArgumentsPreview(tc, toolFeedbackMaxArgsLength)
+}
+
+func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage {
+ if len(toolCalls) == 0 {
+ return nil
+ }
+
+ messages := make([]sessionChatMessage, 0, len(toolCalls))
+ for _, tc := range toolCalls {
+ name, argsJSON := utils.VisibleToolCallNameAndArguments(tc)
+ if name != "message" {
+ continue
+ }
+ content, ok := parseMessageToolContent(argsJSON)
+ if !ok {
+ continue
+ }
+ messages = append(messages, sessionChatMessage{
+ Role: "assistant",
+ Content: content,
+ })
+ }
+
+ return messages
+}
+
+func parseMessageToolContent(argsJSON string) (string, bool) {
+ var args struct {
+ Content string `json:"content"`
+ }
+ if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
+ return "", false
+ }
+ if strings.TrimSpace(args.Content) == "" {
+ return "", false
+ }
+ return args.Content, true
+}
+
// sessionsDir resolves the path to the gateway's session storage directory.
// It reads the workspace from config, falling back to ~/.picoclaw/workspace.
func (h *Handler) sessionsDir() (string, error) {
@@ -255,7 +763,19 @@ func (h *Handler) sessionsDir() (string, error) {
return "", err
}
- workspace := cfg.Agents.Defaults.Workspace
+ return resolveSessionsDir(cfg.Agents.Defaults.Workspace), nil
+}
+
+func (h *Handler) sessionRuntimeSettings() (string, int, error) {
+ cfg, err := config.LoadConfig(h.configPath)
+ if err != nil {
+ return "", 0, err
+ }
+
+ return resolveSessionsDir(cfg.Agents.Defaults.Workspace), cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), nil
+}
+
+func resolveSessionsDir(workspace string) string {
if workspace == "" {
home, _ := os.UserHomeDir()
workspace = filepath.Join(home, ".picoclaw", "workspace")
@@ -271,21 +791,20 @@ func (h *Handler) sessionsDir() (string, error) {
}
}
- return filepath.Join(workspace, "sessions"), nil
+ return filepath.Join(workspace, "sessions")
}
// handleListSessions returns a list of Pico session summaries.
//
// GET /api/sessions
func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
- dir, err := h.sessionsDir()
+ dir, toolFeedbackMaxArgsLength, err := h.sessionRuntimeSettings()
if err != nil {
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
return
}
- entries, err := os.ReadDir(dir)
- if err != nil {
+ if _, err := os.ReadDir(dir); err != nil {
// Directory doesn't exist yet = no sessions
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]sessionListItem{})
@@ -295,74 +814,29 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
items := []sessionListItem{}
seen := make(map[string]struct{})
- for _, entry := range entries {
- if entry.IsDir() {
- continue
+ if refs, findErr := h.findPicoJSONLSessions(dir); findErr == nil {
+ for _, ref := range refs {
+ sess, loadErr := h.readJSONLSession(dir, ref.Key)
+ if loadErr != nil || isEmptySession(sess) {
+ continue
+ }
+ seen[ref.ID] = struct{}{}
+ items = append(items, buildSessionListItem(ref.ID, sess, toolFeedbackMaxArgsLength))
}
+ }
- name := entry.Name()
- var (
- sessionID string
- sess sessionFile
- loadErr error
- ok bool
- )
-
- switch {
- case strings.HasSuffix(name, ".jsonl"):
- sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl"))
- if !ok {
+ if legacyRefs, findErr := h.findLegacyPicoSessions(dir); findErr == nil {
+ for _, ref := range legacyRefs {
+ if _, exists := seen[ref.ID]; exists {
continue
}
- sess, loadErr = h.readJSONLSession(dir, sessionID)
- if loadErr == nil && isEmptySession(sess) {
+ sess, loadErr := h.readLegacySession(ref.Path)
+ if loadErr != nil || isEmptySession(sess) {
continue
}
- case strings.HasSuffix(name, ".meta.json"):
- continue
- case filepath.Ext(name) == ".json":
- base := strings.TrimSuffix(name, ".json")
- if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil {
- if jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found {
- if jsonlSess, jsonlErr := h.readJSONLSession(
- dir,
- jsonlSessionID,
- ); jsonlErr == nil &&
- !isEmptySession(jsonlSess) {
- continue
- }
- }
- }
- data, err := os.ReadFile(filepath.Join(dir, name))
- if err != nil {
- continue
- }
- if err := json.Unmarshal(data, &sess); err != nil {
- continue
- }
- if isEmptySession(sess) {
- continue
- }
- sessionID, ok = extractPicoSessionID(sess.Key)
- if !ok {
- continue
- }
- if _, exists := seen[sessionID]; exists {
- continue
- }
- default:
- continue
+ seen[ref.ID] = struct{}{}
+ items = append(items, buildSessionListItem(ref.ID, sess, toolFeedbackMaxArgsLength))
}
-
- if loadErr != nil {
- continue
- }
- if _, exists := seen[sessionID]; exists {
- continue
- }
-
- seen[sessionID] = struct{}{}
- items = append(items, buildSessionListItem(sessionID, sess))
}
// Sort by updated descending (most recent first)
@@ -410,19 +884,26 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
return
}
- dir, err := h.sessionsDir()
+ dir, toolFeedbackMaxArgsLength, err := h.sessionRuntimeSettings()
if err != nil {
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
return
}
- sess, err := h.readJSONLSession(dir, sessionID)
+ ref, refErr := h.findPicoJSONLSession(dir, sessionID)
+ var sess sessionFile
+ err = refErr
+ if refErr == nil {
+ sess, err = h.readJSONLSession(dir, ref.Key)
+ }
if err == nil && isEmptySession(sess) {
err = os.ErrNotExist
}
if err != nil {
if errors.Is(err, os.ErrNotExist) {
- sess, err = h.readLegacySession(dir, sessionID)
+ if legacyRef, legacyErr := h.findLegacyPicoSession(dir, sessionID); legacyErr == nil {
+ sess, err = h.readLegacySession(legacyRef.Path)
+ }
if err == nil && isEmptySession(sess) {
err = os.ErrNotExist
}
@@ -437,22 +918,7 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
}
}
- // Convert to a simpler format for the frontend
- type chatMessage struct {
- Role string `json:"role"`
- Content string `json:"content"`
- }
-
- messages := make([]chatMessage, 0, len(sess.Messages))
- for _, msg := range sess.Messages {
- // Only include user and assistant messages that have actual content
- if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
- messages = append(messages, chatMessage{
- Role: msg.Role,
- Content: msg.Content,
- })
- }
- }
+ messages := detailSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
@@ -480,21 +946,30 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
return
}
- base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
- jsonlPath := base + ".jsonl"
- metaPath := base + ".meta.json"
- legacyPath := base + ".json"
-
removed := false
- for _, path := range []string{jsonlPath, metaPath, legacyPath} {
- if err := os.Remove(path); err != nil {
- if os.IsNotExist(err) {
- continue
+ if ref, err := h.findPicoJSONLSession(dir, sessionID); err == nil {
+ base := filepath.Join(dir, sanitizeSessionKey(ref.Key))
+ for _, path := range []string{base + ".jsonl", base + ".meta.json"} {
+ if err := os.Remove(path); err != nil {
+ if os.IsNotExist(err) {
+ continue
+ }
+ http.Error(w, "failed to delete session", http.StatusInternalServerError)
+ return
}
- http.Error(w, "failed to delete session", http.StatusInternalServerError)
- return
+ removed = true
+ }
+ }
+
+ if legacyRef, err := h.findLegacyPicoSession(dir, sessionID); err == nil {
+ if err := os.Remove(legacyRef.Path); err != nil {
+ if !os.IsNotExist(err) {
+ http.Error(w, "failed to delete session", http.StatusInternalServerError)
+ return
+ }
+ } else {
+ removed = true
}
- removed = true
}
if !removed {
diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go
index 21ef5b5b8..760935db7 100644
--- a/web/backend/api/session_test.go
+++ b/web/backend/api/session_test.go
@@ -6,12 +6,15 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "strings"
"testing"
+ "time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session"
+ "github.com/sipeed/picoclaw/pkg/utils"
)
func sessionsTestDir(t *testing.T, configPath string) string {
@@ -29,17 +32,36 @@ func sessionsTestDir(t *testing.T, configPath string) string {
return dir
}
+func assertVisibleToolCallMessage(
+ t *testing.T,
+ msg sessionChatMessage,
+ toolName string,
+) utils.VisibleToolCall {
+ t.Helper()
+
+ if msg.Role != "assistant" || msg.Kind != "tool_calls" {
+ t.Fatalf("message = %#v, want assistant/tool_calls", msg)
+ }
+ if len(msg.ToolCalls) != 1 {
+ t.Fatalf("len(message.ToolCalls) = %d, want 1", len(msg.ToolCalls))
+ }
+ if got := msg.ToolCalls[0].Function; got == nil || got.Name != toolName {
+ t.Fatalf("tool call = %#v, want function %q", msg.ToolCalls[0], toolName)
+ }
+ return msg.ToolCalls[0]
+}
+
func TestHandleListSessions_JSONLStorage(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
- store, err := memory.NewJSONLStore(dir)
- if err != nil {
- t.Fatalf("NewJSONLStore() error = %v", err)
+ store, storeErr := memory.NewJSONLStore(dir)
+ if storeErr != nil {
+ t.Fatalf("NewJSONLStore() error = %v", storeErr)
}
- sessionKey := picoSessionPrefix + "history-jsonl"
+ sessionKey := legacyPicoSessionPrefix + "history-jsonl"
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
Role: "user",
Content: "Explain why the history API is empty after migration.",
@@ -87,25 +109,87 @@ func TestHandleListSessions_JSONLStorage(t *testing.T) {
if items[0].MessageCount != 2 {
t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount)
}
- if items[0].Title != "JSONL-backed session" {
- t.Fatalf("items[0].Title = %q, want %q", items[0].Title, "JSONL-backed session")
+ if items[0].Title != "Explain why the history API is empty after migration." {
+ t.Fatalf(
+ "items[0].Title = %q, want %q",
+ items[0].Title,
+ "Explain why the history API is empty after migration.",
+ )
}
if items[0].Preview != "Explain why the history API is empty after migration." {
t.Fatalf("items[0].Preview = %q", items[0].Preview)
}
}
-func TestHandleListSessions_TitleUsesTrimmedSummary(t *testing.T) {
+func TestHandleListSessions_TransientThoughtDoesNotInflateMessageCount(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
- store, err := memory.NewJSONLStore(dir)
+ sessionKey := legacyPicoSessionPrefix + "history-jsonl-transient"
+ base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
+ now := time.Now().UTC()
+
+ rawJSONL := strings.Join([]string{
+ `{"role":"user","content":"keep me"}`,
+ `{"role":"assistant","content":"","reasoning_content":"dangling thought"}`,
+ `{"role":"assistant","content":"and me"}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(base+".jsonl", []byte(rawJSONL), 0o644); err != nil {
+ t.Fatalf("WriteFile(jsonl) error = %v", err)
+ }
+ metaData, err := json.Marshal(memory.SessionMeta{
+ Key: sessionKey,
+ Count: 3,
+ Skip: 0,
+ CreatedAt: now,
+ UpdatedAt: now,
+ })
if err != nil {
- t.Fatalf("NewJSONLStore() error = %v", err)
+ t.Fatalf("Marshal(meta) error = %v", err)
+ }
+ if err := os.WriteFile(base+".meta.json", metaData, 0o644); err != nil {
+ t.Fatalf("WriteFile(meta) error = %v", err)
}
- sessionKey := picoSessionPrefix + "summary-title"
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var items []sessionListItem
+ if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("len(items) = %d, want 1", len(items))
+ }
+ if items[0].ID != "history-jsonl-transient" {
+ t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "history-jsonl-transient")
+ }
+ if items[0].MessageCount != 2 {
+ t.Fatalf("items[0].MessageCount = %d, want 2 after dropping transient thought", items[0].MessageCount)
+ }
+}
+
+func TestHandleListSessions_TitleUsesFirstUserMessage(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, storeErr := memory.NewJSONLStore(dir)
+ if storeErr != nil {
+ t.Fatalf("NewJSONLStore() error = %v", storeErr)
+ }
+
+ sessionKey := legacyPicoSessionPrefix + "summary-title"
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
Role: "user",
Content: "fallback preview",
@@ -139,10 +223,7 @@ func TestHandleListSessions_TitleUsesTrimmedSummary(t *testing.T) {
if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items))
}
- expectedTitle := truncateRunes(
- "This summary is intentionally longer than sixty characters so it must be truncated in the history menu.",
- maxSessionTitleRunes,
- )
+ expectedTitle := truncateRunes("fallback preview", maxSessionTitleRunes)
if items[0].Title != expectedTitle {
t.Fatalf("items[0].Title = %q", items[0].Title)
}
@@ -161,7 +242,7 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) {
t.Fatalf("NewJSONLStore() error = %v", err)
}
- sessionKey := picoSessionPrefix + "detail-jsonl"
+ sessionKey := legacyPicoSessionPrefix + "detail-jsonl"
for _, msg := range []providers.Message{
{Role: "user", Content: "first"},
{Role: "assistant", Content: "second"},
@@ -215,6 +296,1275 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) {
}
}
+func TestHandleGetSession_HidesHandledToolAttachmentsBackedByMediaRefs(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := legacyPicoSessionPrefix + "attachment-history"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "send me the report"},
+ {
+ Role: "assistant",
+ Content: handledToolResponseSummaryText,
+ Attachments: []providers.Attachment{{
+ Type: "file",
+ Ref: "media://attachment-1",
+ Filename: "report.txt",
+ ContentType: "text/plain",
+ }},
+ },
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/attachment-history", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ if len(resp.Messages) != 1 {
+ t.Fatalf("len(resp.Messages) = %d, want 1", len(resp.Messages))
+ }
+ if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "send me the report" {
+ t.Fatalf("message = %#v, want only user request", resp.Messages[0])
+ }
+}
+
+func TestHandleGetSession_ExposesHandledToolAttachmentsWithDurableURL(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := legacyPicoSessionPrefix + "attachment-history-durable"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "send me the report"},
+ {
+ Role: "assistant",
+ Content: handledToolResponseSummaryText,
+ Attachments: []providers.Attachment{{
+ Type: "file",
+ URL: "https://example.com/report.txt",
+ Filename: "report.txt",
+ ContentType: "text/plain",
+ }},
+ },
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/attachment-history-durable", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ if len(resp.Messages) != 2 {
+ t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
+ }
+
+ assistant := resp.Messages[1]
+ if assistant.Role != "assistant" {
+ t.Fatalf("assistant role = %q, want assistant", assistant.Role)
+ }
+ if assistant.Content != "" {
+ t.Fatalf("assistant content = %q, want empty string", assistant.Content)
+ }
+ if len(assistant.Attachments) != 1 {
+ t.Fatalf("len(assistant.Attachments) = %d, want 1", len(assistant.Attachments))
+ }
+ if assistant.Attachments[0].URL != "https://example.com/report.txt" {
+ t.Fatalf(
+ "attachment url = %q, want %q",
+ assistant.Attachments[0].URL,
+ "https://example.com/report.txt",
+ )
+ }
+ if assistant.Attachments[0].Filename != "report.txt" {
+ t.Fatalf("attachment filename = %q, want %q", assistant.Attachments[0].Filename, "report.txt")
+ }
+}
+
+func TestHandleSessions_JSONLScopeDiscovery(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, storeErr := memory.NewJSONLStore(dir)
+ if storeErr != nil {
+ t.Fatalf("NewJSONLStore() error = %v", storeErr)
+ }
+
+ sessionKey := "sk_v1_scope_discovery"
+ if err := store.AddFullMessage(nil, sessionKey, providers.Message{
+ Role: "user",
+ Content: "scope discovered session",
+ }); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ if err := store.SetSummary(nil, sessionKey, "scope summary"); err != nil {
+ t.Fatalf("SetSummary() error = %v", err)
+ }
+
+ scopeData, err := json.Marshal(session.SessionScope{
+ Version: session.ScopeVersionV1,
+ AgentID: "main",
+ Channel: "pico",
+ Account: "default",
+ Dimensions: []string{"sender"},
+ Values: map[string]string{
+ "sender": "pico:scope-jsonl",
+ },
+ })
+ if err != nil {
+ t.Fatalf("Marshal(scope) error = %v", err)
+ }
+ if err := store.UpsertSessionMeta(nil, sessionKey, scopeData, nil); err != nil {
+ t.Fatalf("UpsertSessionMeta() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ listRec := httptest.NewRecorder()
+ listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
+ mux.ServeHTTP(listRec, listReq)
+ if listRec.Code != http.StatusOK {
+ t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String())
+ }
+
+ var items []sessionListItem
+ if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil {
+ t.Fatalf("Unmarshal(list) error = %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("len(items) = %d, want 1", len(items))
+ }
+ if items[0].ID != "scope-jsonl" {
+ t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "scope-jsonl")
+ }
+
+ detailRec := httptest.NewRecorder()
+ detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/scope-jsonl", nil)
+ mux.ServeHTTP(detailRec, detailReq)
+ if detailRec.Code != http.StatusOK {
+ t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusOK, detailRec.Body.String())
+ }
+
+ deleteRec := httptest.NewRecorder()
+ deleteReq := httptest.NewRequest(http.MethodDelete, "/api/sessions/scope-jsonl", nil)
+ mux.ServeHTTP(deleteRec, deleteReq)
+ if deleteRec.Code != http.StatusNoContent {
+ t.Fatalf("delete status = %d, want %d, body=%s", deleteRec.Code, http.StatusNoContent, deleteRec.Body.String())
+ }
+}
+
+func TestHandleGetSession_SkipsTransientThoughtMessages(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-transient-thought"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "hello"},
+ {Role: "assistant", ReasoningContent: "internal chain of thought"},
+ {Role: "assistant", Content: "final visible answer"},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-transient-thought", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 2 {
+ t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
+ }
+ if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "hello" {
+ t.Fatalf("first message = %#v, want user/hello", resp.Messages[0])
+ }
+ if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "final visible answer" {
+ t.Fatalf("second message = %#v, want assistant/final visible answer", resp.Messages[1])
+ }
+}
+
+func TestHandleGetSession_ReconstructsThoughtFromAssistantReasoningContent(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-reasoning-content"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "hello"},
+ {Role: "assistant", Content: "final visible answer", ReasoningContent: "internal chain of thought"},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-reasoning-content", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 3 {
+ t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
+ }
+ if resp.Messages[1].Role != "assistant" ||
+ resp.Messages[1].Content != "internal chain of thought" ||
+ resp.Messages[1].Kind != "thought" {
+ t.Fatalf("thought message = %#v, want assistant thought/internal chain of thought", resp.Messages[1])
+ }
+ if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final visible answer" {
+ t.Fatalf("final message = %#v, want assistant/final visible answer", resp.Messages[2])
+ }
+}
+
+func TestHandleGetSession_ReconstructsRefreshMatrixForThoughtAndToolSummary(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-refresh-matrix"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "turn1"},
+ {Role: "assistant", Content: "plain visible", ReasoningContent: "plain thought"},
+ {Role: "user", Content: "turn2"},
+ {
+ Role: "assistant",
+ ReasoningContent: "tool thought",
+ ToolCalls: []providers.ToolCall{{
+ ID: "call_read_file",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: `{"path":"README.md"}`,
+ },
+ }},
+ },
+ {Role: "tool", ToolCallID: "call_read_file", Content: "file result"},
+ {Role: "user", Content: "turn3"},
+ {
+ Role: "assistant",
+ Content: "tool visible only",
+ ToolCalls: []providers.ToolCall{{
+ ID: "call_list_dir",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "list_dir",
+ Arguments: `{"path":"."}`,
+ },
+ }},
+ },
+ {Role: "tool", ToolCallID: "call_list_dir", Content: "dir result"},
+ {Role: "user", Content: "turn4"},
+ {
+ Role: "assistant",
+ Content: "tool visible and thought",
+ ReasoningContent: "tool mixed thought",
+ ToolCalls: []providers.ToolCall{{
+ ID: "call_exec",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "exec",
+ Arguments: `{"command":"pwd"}`,
+ },
+ }},
+ },
+ {Role: "tool", ToolCallID: "call_exec", Content: "pwd result"},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-refresh-matrix", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ if len(resp.Messages) != 13 {
+ t.Fatalf("len(resp.Messages) = %d, want 13", len(resp.Messages))
+ }
+
+ assertMessage := func(index int, role, kind, content string) {
+ t.Helper()
+ msg := resp.Messages[index]
+ if msg.Role != role || msg.Kind != kind || msg.Content != content {
+ t.Fatalf("messages[%d] = %#v, want role=%q kind=%q content=%q", index, msg, role, kind, content)
+ }
+ }
+
+ assertMessage(0, "user", "", "turn1")
+ assertMessage(1, "assistant", "thought", "plain thought")
+ assertMessage(2, "assistant", "", "plain visible")
+ assertMessage(3, "user", "", "turn2")
+ assertMessage(4, "assistant", "thought", "tool thought")
+ assertVisibleToolCallMessage(t, resp.Messages[5], "read_file")
+ assertMessage(6, "user", "", "turn3")
+ assertMessage(7, "assistant", "", "tool visible only")
+ assertVisibleToolCallMessage(t, resp.Messages[8], "list_dir")
+ assertMessage(9, "user", "", "turn4")
+ assertMessage(10, "assistant", "thought", "tool mixed thought")
+ assertMessage(11, "assistant", "", "tool visible and thought")
+ assertVisibleToolCallMessage(t, resp.Messages[12], "exec")
+}
+
+func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSummary(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-message-tool"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "test"},
+ {
+ Role: "assistant",
+ Content: "",
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "message",
+ Arguments: `{"content":"visible tool output"}`,
+ },
+ },
+ },
+ },
+ {Role: "tool", Content: "Message sent to pico:pico:detail-message-tool", ToolCallID: "call_1"},
+ {Role: "assistant", Content: handledToolResponseSummaryText},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-message-tool", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 3 {
+ t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
+ }
+ if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" {
+ t.Fatalf("first message = %#v, want user/test", resp.Messages[0])
+ }
+ assertVisibleToolCallMessage(t, resp.Messages[1], "message")
+ if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" {
+ t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[2])
+ }
+}
+
+func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-message-tool-final-reply"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "test"},
+ {
+ Role: "assistant",
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "message",
+ Arguments: `{"content":"visible tool output"}`,
+ },
+ },
+ },
+ },
+ {Role: "tool", Content: "Message sent to pico:pico:detail-message-tool-final-reply", ToolCallID: "call_1"},
+ {Role: "assistant", Content: "final assistant reply"},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-message-tool-final-reply", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 4 {
+ t.Fatalf("len(resp.Messages) = %d, want 4", len(resp.Messages))
+ }
+ if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" {
+ t.Fatalf("first message = %#v, want user/test", resp.Messages[0])
+ }
+ assertVisibleToolCallMessage(t, resp.Messages[1], "message")
+ if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" {
+ t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[2])
+ }
+ if resp.Messages[3].Role != "assistant" || resp.Messages[3].Content != "final assistant reply" {
+ t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[3])
+ }
+}
+
+func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "list-visible-count"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "test"},
+ {
+ Role: "assistant",
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "message",
+ Arguments: `{"content":"visible tool output"}`,
+ },
+ },
+ },
+ },
+ {Role: "tool", Content: "Message sent to pico:pico:list-visible-count", ToolCallID: "call_1"},
+ {Role: "assistant", Content: handledToolResponseSummaryText},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var items []sessionListItem
+ if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("len(items) = %d, want 1", len(items))
+ }
+ if items[0].MessageCount != 3 {
+ t.Fatalf("items[0].MessageCount = %d, want 3", items[0].MessageCount)
+ }
+}
+
+func TestHandleListSessions_DeduplicatesAssistantToolCallContentFromVisibleTranscript(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "list-deduped-tool-content"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "check file"},
+ {
+ Role: "assistant",
+ Content: "Read the file before replying.",
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: `{"path":"README.md"}`,
+ },
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: "Read the file before replying.",
+ },
+ },
+ },
+ },
+ {Role: "tool", Content: "raw read_file result", ToolCallID: "call_1"},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var items []sessionListItem
+ if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("len(items) = %d, want 1", len(items))
+ }
+ if items[0].MessageCount != 2 {
+ t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount)
+ }
+}
+
+func TestHandleGetSession_DoesNotDuplicateAssistantToolCallContent(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-tool-summary-and-content"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "check file"},
+ {
+ Role: "assistant",
+ Content: "Read the file before replying.",
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: `{"path":"README.md","start_line":1,"end_line":10}`,
+ },
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: "Read the file before replying.",
+ },
+ },
+ },
+ },
+ {Role: "tool", Content: "raw read_file result", ToolCallID: "call_1"},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-and-content", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 2 {
+ t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
+ }
+ if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" {
+ t.Fatalf("first message = %#v, want user/check file", resp.Messages[0])
+ }
+ toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file")
+ if toolCall.ExtraContent == nil ||
+ toolCall.ExtraContent.ToolFeedbackExplanation != "Read the file before replying." {
+ t.Fatalf("tool call = %#v, want explanation", toolCall)
+ }
+}
+
+func TestHandleGetSession_PreservesDistinctAssistantToolCallContent(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-tool-summary-distinct-content"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "check file"},
+ {
+ Role: "assistant",
+ Content: "I will summarize the findings after reading the file.",
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: `{"path":"README.md","start_line":1,"end_line":10}`,
+ },
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: "Read the file before replying.",
+ },
+ },
+ },
+ },
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-distinct-content", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 3 {
+ t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
+ }
+ if resp.Messages[1].Role != "assistant" ||
+ resp.Messages[1].Content != "I will summarize the findings after reading the file." {
+ t.Fatalf("assistant content = %#v, want preserved distinct content", resp.Messages[1])
+ }
+ assertVisibleToolCallMessage(t, resp.Messages[2], "read_file")
+}
+
+func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-tool-summary-duplicate-content-with-media"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "check screenshot"},
+ {
+ Role: "assistant",
+ Content: "Reviewing the generated screenshot.",
+ Media: []string{"data:image/png;base64,abc123"},
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "view_image",
+ Arguments: `{"path":"artifact.png"}`,
+ },
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: "Reviewing the generated screenshot.",
+ },
+ },
+ },
+ },
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-duplicate-content-with-media", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 3 {
+ t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
+ }
+ if resp.Messages[1].Role != "assistant" {
+ t.Fatalf("assistant message role = %q, want assistant", resp.Messages[1].Role)
+ }
+ if resp.Messages[1].Content != "" {
+ t.Fatalf("assistant content = %q, want duplicate content suppressed", resp.Messages[1].Content)
+ }
+ if len(resp.Messages[1].Media) != 1 || resp.Messages[1].Media[0] != "data:image/png;base64,abc123" {
+ t.Fatalf("assistant media = %#v, want preserved media", resp.Messages[1].Media)
+ }
+ assertVisibleToolCallMessage(t, resp.Messages[2], "view_image")
+}
+
+func TestHandleGetSession_PreservesAttachmentsWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-tool-summary-duplicate-content-with-attachments"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "check report"},
+ {
+ Role: "assistant",
+ Content: "Reviewing the generated report.",
+ Attachments: []providers.Attachment{{
+ Type: "file",
+ URL: "https://example.com/report.txt",
+ Filename: "report.txt",
+ ContentType: "text/plain",
+ }},
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: `{"path":"report.txt"}`,
+ },
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: "Reviewing the generated report.",
+ },
+ },
+ },
+ },
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/api/sessions/detail-tool-summary-duplicate-content-with-attachments",
+ nil,
+ )
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 3 {
+ t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
+ }
+ if resp.Messages[1].Role != "assistant" {
+ t.Fatalf("assistant message role = %q, want assistant", resp.Messages[1].Role)
+ }
+ if resp.Messages[1].Content != "" {
+ t.Fatalf("assistant content = %q, want duplicate content suppressed", resp.Messages[1].Content)
+ }
+ if len(resp.Messages[1].Attachments) != 1 {
+ t.Fatalf("len(assistant.Attachments) = %d, want 1", len(resp.Messages[1].Attachments))
+ }
+ if resp.Messages[1].Attachments[0].URL != "https://example.com/report.txt" {
+ t.Fatalf("attachment url = %q, want report URL", resp.Messages[1].Attachments[0].URL)
+ }
+ assertVisibleToolCallMessage(t, resp.Messages[2], "read_file")
+}
+
+func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.Agents.Defaults.ToolFeedback.MaxArgsLength = 20
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}`
+ explanation := "Read README.md first to confirm the current project structure before editing the config example."
+ sessionKey := picoSessionPrefix + "detail-tool-summary-max-args"
+ err = store.AddFullMessage(nil, sessionKey, providers.Message{Role: "user", Content: "check file"})
+ if err != nil {
+ t.Fatalf("AddFullMessage(user) error = %v", err)
+ }
+ err = store.AddFullMessage(nil, sessionKey, providers.Message{
+ Role: "assistant",
+ ToolCalls: []providers.ToolCall{{
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: argsJSON,
+ },
+ ExtraContent: &providers.ExtraContent{
+ ToolFeedbackExplanation: explanation,
+ },
+ }},
+ })
+ if err != nil {
+ t.Fatalf("AddFullMessage(assistant) error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-max-args", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ err = json.Unmarshal(rec.Body.Bytes(), &resp)
+ if err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) < 2 {
+ t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages))
+ }
+
+ wantArgsPreview := visibleAssistantToolArgsPreview(providers.ToolCall{
+ Function: &providers.FunctionCall{Arguments: argsJSON},
+ }, 20)
+ toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file")
+ if toolCall.ExtraContent == nil || toolCall.ExtraContent.ToolFeedbackExplanation != explanation {
+ t.Fatalf("tool call = %#v, want full explanation %q", toolCall, explanation)
+ }
+ if toolCall.Function == nil || toolCall.Function.Arguments != wantArgsPreview {
+ t.Fatalf("tool call = %#v, want args preview %q", toolCall, wantArgsPreview)
+ }
+}
+
+func TestHandleGetSession_FallsBackToLegacyToolArgumentsWhenExplanationMissing(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.Agents.Defaults.ToolFeedback.MaxArgsLength = 20
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}`
+ sessionKey := picoSessionPrefix + "detail-tool-summary-legacy-args"
+ if err := store.AddFullMessage(
+ nil,
+ sessionKey,
+ providers.Message{Role: "user", Content: "check file"},
+ ); err != nil {
+ t.Fatalf("AddFullMessage(user) error = %v", err)
+ }
+ if err := store.AddFullMessage(nil, sessionKey, providers.Message{
+ Role: "assistant",
+ ToolCalls: []providers.ToolCall{{
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: argsJSON,
+ },
+ }},
+ }); err != nil {
+ t.Fatalf("AddFullMessage(assistant) error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-legacy-args", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []sessionChatMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) < 2 {
+ t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages))
+ }
+
+ wantPreview := visibleAssistantToolArgsPreview(providers.ToolCall{
+ Function: &providers.FunctionCall{Arguments: argsJSON},
+ }, 20)
+ toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file")
+ if toolCall.Function == nil || toolCall.Function.Arguments != wantPreview {
+ t.Fatalf("tool call = %#v, want legacy args preview %q", toolCall, wantPreview)
+ }
+}
+
+func TestHandleGetSession_IncludesMediaOnlyMessages(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-media-only"
+ if err := store.AddFullMessage(nil, sessionKey, providers.Message{
+ Role: "user",
+ Media: []string{"data:image/png;base64,abc123"},
+ }); err != nil {
+ t.Fatalf("AddFullMessage(user) error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-media-only", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ Media []string `json:"media"`
+ } `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 1 {
+ t.Fatalf("len(resp.Messages) = %d, want 1", len(resp.Messages))
+ }
+ if resp.Messages[0].Role != "user" || len(resp.Messages[0].Media) != 1 {
+ t.Fatalf("message = %#v, want user message with media", resp.Messages[0])
+ }
+}
+
+func TestHandleSessions_SupportsJSONLMessagesUpToStoreCap(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-large-jsonl"
+ largeContent := strings.Repeat("x", 9*1024*1024)
+ if err := store.AddFullMessage(nil, sessionKey, providers.Message{
+ Role: "user",
+ Content: largeContent,
+ }); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ listRec := httptest.NewRecorder()
+ listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
+ mux.ServeHTTP(listRec, listReq)
+
+ if listRec.Code != http.StatusOK {
+ t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String())
+ }
+
+ var items []sessionListItem
+ if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil {
+ t.Fatalf("list Unmarshal() error = %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("len(items) = %d, want 1", len(items))
+ }
+
+ detailRec := httptest.NewRecorder()
+ detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-large-jsonl", nil)
+ mux.ServeHTTP(detailRec, detailReq)
+
+ if detailRec.Code != http.StatusOK {
+ t.Fatalf(
+ "detail status = %d, want %d, body=%s",
+ detailRec.Code,
+ http.StatusOK,
+ detailRec.Body.String(),
+ )
+ }
+
+ var resp struct {
+ Messages []struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"messages"`
+ }
+ if err := json.Unmarshal(detailRec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("detail Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 1 {
+ t.Fatalf("len(resp.Messages) = %d, want 1", len(resp.Messages))
+ }
+ if resp.Messages[0].Role != "user" {
+ t.Fatalf("resp.Messages[0].Role = %q, want %q", resp.Messages[0].Role, "user")
+ }
+ if got := len(resp.Messages[0].Content); got != len(largeContent) {
+ t.Fatalf("len(resp.Messages[0].Content) = %d, want %d", got, len(largeContent))
+ }
+}
+
+func TestHandleListSessions_UsesImagePreviewForMediaOnlyMessage(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "preview-media-only"
+ if err := store.AddFullMessage(nil, sessionKey, providers.Message{
+ Role: "user",
+ Media: []string{"data:image/png;base64,abc123"},
+ }); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var items []sessionListItem
+ if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("len(items) = %d, want 1", len(items))
+ }
+ if items[0].Preview != "[image]" {
+ t.Fatalf("items[0].Preview = %q, want %q", items[0].Preview, "[image]")
+ }
+ if items[0].MessageCount != 1 {
+ t.Fatalf("items[0].MessageCount = %d, want 1", items[0].MessageCount)
+ }
+}
+
func TestHandleDeleteSession_JSONLStorage(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -225,7 +1575,7 @@ func TestHandleDeleteSession_JSONLStorage(t *testing.T) {
t.Fatalf("NewJSONLStore() error = %v", err)
}
- sessionKey := picoSessionPrefix + "delete-jsonl"
+ sessionKey := legacyPicoSessionPrefix + "delete-jsonl"
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
Role: "user",
Content: "delete me",
@@ -262,7 +1612,7 @@ func TestHandleGetSession_LegacyJSONFallback(t *testing.T) {
dir := sessionsTestDir(t, configPath)
manager := session.NewSessionManager(dir)
- sessionKey := picoSessionPrefix + "legacy-json"
+ sessionKey := legacyPicoSessionPrefix + "legacy-json"
manager.AddMessage(sessionKey, "user", "legacy user")
manager.AddMessage(sessionKey, "assistant", "legacy assistant")
if err := manager.Save(sessionKey); err != nil {
@@ -287,7 +1637,7 @@ func TestHandleSessions_FiltersEmptyJSONLFiles(t *testing.T) {
defer cleanup()
dir := sessionsTestDir(t, configPath)
- base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+"empty-jsonl"))
+ base := filepath.Join(dir, sanitizeSessionKey(legacyPicoSessionPrefix+"empty-jsonl"))
if err := os.WriteFile(base+".jsonl", []byte{}, 0o644); err != nil {
t.Fatalf("WriteFile(jsonl) error = %v", err)
}
@@ -320,3 +1670,82 @@ func TestHandleSessions_FiltersEmptyJSONLFiles(t *testing.T) {
t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusNotFound, detailRec.Body.String())
}
}
+
+func TestHandleSessions_ListsLegacyJSONLWithoutMeta(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ sessionKey := legacyPicoSessionPrefix + "missing-meta"
+ base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
+ line, err := json.Marshal(providers.Message{Role: "user", Content: "recover me"})
+ if err != nil {
+ t.Fatalf("Marshal(message) error = %v", err)
+ }
+ if err := os.WriteFile(base+".jsonl", append(line, '\n'), 0o644); err != nil {
+ t.Fatalf("WriteFile(jsonl) error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ listRec := httptest.NewRecorder()
+ listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
+ mux.ServeHTTP(listRec, listReq)
+
+ if listRec.Code != http.StatusOK {
+ t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String())
+ }
+
+ var items []sessionListItem
+ if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil {
+ t.Fatalf("Unmarshal(list) error = %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("len(items) = %d, want 1", len(items))
+ }
+ if items[0].ID != "missing-meta" {
+ t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "missing-meta")
+ }
+
+ detailRec := httptest.NewRecorder()
+ detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/missing-meta", nil)
+ mux.ServeHTTP(detailRec, detailReq)
+
+ if detailRec.Code != http.StatusOK {
+ t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusOK, detailRec.Body.String())
+ }
+}
+
+func TestHandleSessions_IgnoresMetaJSONInLegacyFallback(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ metaOnly := filepath.Join(dir, "agent_main_pico_direct_pico_meta-only.meta.json")
+ metaOnlyContent := []byte(`{"key":"agent:main:pico:direct:pico:meta-only","summary":"meta only"}`)
+ if err := os.WriteFile(metaOnly, metaOnlyContent, 0o644); err != nil {
+ t.Fatalf("WriteFile(meta) error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ listRec := httptest.NewRecorder()
+ listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
+ mux.ServeHTTP(listRec, listReq)
+
+ if listRec.Code != http.StatusOK {
+ t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String())
+ }
+
+ var items []sessionListItem
+ if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil {
+ t.Fatalf("Unmarshal(list) error = %v", err)
+ }
+ if len(items) != 0 {
+ t.Fatalf("len(items) = %d, want 0", len(items))
+ }
+}
diff --git a/web/backend/api/skills.go b/web/backend/api/skills.go
index 3c2fb57dd..e89ff7c30 100644
--- a/web/backend/api/skills.go
+++ b/web/backend/api/skills.go
@@ -1,40 +1,116 @@
package api
import (
+ "bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
+ "io/fs"
"net/http"
"os"
"path/filepath"
"regexp"
+ "strconv"
"strings"
+ "sync"
+ "time"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/skills"
+ "github.com/sipeed/picoclaw/pkg/utils"
)
+const defaultInstallSkillRegistry = "github"
+
type skillSupportResponse struct {
- Skills []skills.SkillInfo `json:"skills"`
+ Skills []skillSupportItem `json:"skills"`
+}
+
+type skillSupportItem struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Source string `json:"source"`
+ Description string `json:"description"`
+ OriginKind string `json:"origin_kind"`
+ RegistryName string `json:"registry_name,omitempty"`
+ RegistryURL string `json:"registry_url,omitempty"`
+ InstalledVersion string `json:"installed_version,omitempty"`
+ InstalledAt int64 `json:"installed_at,omitempty"`
}
type skillDetailResponse struct {
- Name string `json:"name"`
- Path string `json:"path"`
- Source string `json:"source"`
- Description string `json:"description"`
- Content string `json:"content"`
+ skillSupportItem
+ Content string `json:"content"`
+}
+
+type skillSearchResultItem struct {
+ Score float64 `json:"score"`
+ Slug string `json:"slug"`
+ DisplayName string `json:"display_name"`
+ Summary string `json:"summary"`
+ Version string `json:"version"`
+ RegistryName string `json:"registry_name"`
+ URL string `json:"url,omitempty"`
+ Installed bool `json:"installed"`
+ InstalledName string `json:"installed_name,omitempty"`
+}
+
+type skillSearchResponse struct {
+ Results []skillSearchResultItem `json:"results"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+ NextOffset int `json:"next_offset,omitempty"`
+ HasMore bool `json:"has_more"`
+}
+
+type installSkillRequest struct {
+ Slug string `json:"slug"`
+ Registry string `json:"registry"`
+ Version string `json:"version,omitempty"`
+ Force bool `json:"force,omitempty"`
+}
+
+type installSkillResponse struct {
+ Status string `json:"status"`
+ Slug string `json:"slug"`
+ Registry string `json:"registry"`
+ Version string `json:"version"`
+ Summary string `json:"summary,omitempty"`
+ IsSuspicious bool `json:"is_suspicious,omitempty"`
+ InstalledSkill *skillSupportItem `json:"skill,omitempty"`
+}
+
+type installedSkillOriginMeta struct {
+ Version int `json:"version"`
+ OriginKind string `json:"origin_kind,omitempty"`
+ Registry string `json:"registry,omitempty"`
+ Slug string `json:"slug,omitempty"`
+ RegistryURL string `json:"registry_url,omitempty"`
+ InstalledVersion string `json:"installed_version,omitempty"`
+ InstalledAt int64 `json:"installed_at"`
}
var (
skillNameSanitizer = regexp.MustCompile(`[^a-z0-9-]+`)
importedSkillFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
skillFrontmatterStripper = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
+ persistSkillOriginMeta = writeSkillOriginMeta
+ workspaceSkillWriteMu sync.Mutex
+ errImportedSkillExists = errors.New("skill already exists")
+)
+
+const (
+ maxImportedSkillSize = 1 << 20
+ maxRegistrySearchFanout = 1000
)
func (h *Handler) registerSkillRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/skills", h.handleListSkills)
mux.HandleFunc("GET /api/skills/{name}", h.handleGetSkill)
+ mux.HandleFunc("GET /api/skills/search", h.handleSearchSkills)
+ mux.HandleFunc("POST /api/skills/install", h.handleInstallSkill)
mux.HandleFunc("POST /api/skills/import", h.handleImportSkill)
mux.HandleFunc("DELETE /api/skills/{name}", h.handleDeleteSkill)
}
@@ -46,11 +122,15 @@ func (h *Handler) handleListSkills(w http.ResponseWriter, r *http.Request) {
return
}
- loader := newSkillsLoader(cfg.WorkspacePath())
+ items, err := buildSkillSupportItems(cfg)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to build skill list: %v", err), http.StatusInternalServerError)
+ return
+ }
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(skillSupportResponse{
- Skills: loader.ListSkills(),
+ Skills: items,
})
}
@@ -61,16 +141,18 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {
return
}
- loader := newSkillsLoader(cfg.WorkspacePath())
+ skillItems, err := buildSkillSupportItems(cfg)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to build skill list: %v", err), http.StatusInternalServerError)
+ return
+ }
name := r.PathValue("name")
- allSkills := loader.ListSkills()
-
- for _, skill := range allSkills {
- if skill.Name != name {
+ for _, skillItem := range skillItems {
+ if skillItem.Name != name {
continue
}
- content, err := loadSkillContent(skill.Path)
+ content, err := loadSkillContent(skillItem.Path)
if err != nil {
http.Error(w, "Skill content not found", http.StatusNotFound)
return
@@ -78,11 +160,8 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(skillDetailResponse{
- Name: skill.Name,
- Path: skill.Path,
- Source: skill.Source,
- Description: skill.Description,
- Content: content,
+ skillSupportItem: skillItem,
+ Content: content,
})
return
}
@@ -90,6 +169,276 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Skill not found", http.StatusNotFound)
}
+func (h *Handler) handleSearchSkills(w http.ResponseWriter, r *http.Request) {
+ cfg, loadErr := config.LoadConfig(h.configPath)
+ if loadErr != nil {
+ http.Error(w, fmt.Sprintf("Failed to load config: %v", loadErr), http.StatusInternalServerError)
+ return
+ }
+ if registryErr := ensureSkillRegistryToolEnabled(cfg, "find_skills"); registryErr != nil {
+ http.Error(w, registryErr.Error(), http.StatusBadRequest)
+ return
+ }
+
+ query := strings.TrimSpace(r.URL.Query().Get("q"))
+
+ limit := 20
+ if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" {
+ parsedLimit, parseErr := strconv.Atoi(rawLimit)
+ if parseErr != nil || parsedLimit < 1 || parsedLimit > 50 {
+ http.Error(w, "limit must be between 1 and 50", http.StatusBadRequest)
+ return
+ }
+ limit = parsedLimit
+ }
+ offset := 0
+ if rawOffset := strings.TrimSpace(r.URL.Query().Get("offset")); rawOffset != "" {
+ parsedOffset, parseErr := strconv.Atoi(rawOffset)
+ if parseErr != nil || parsedOffset < 0 {
+ http.Error(w, "offset must be 0 or greater", http.StatusBadRequest)
+ return
+ }
+ offset = parsedOffset
+ }
+
+ installedSkills, err := buildOccupiedWorkspaceSkillsByDirectory(cfg)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to inspect installed skills: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ if query == "" {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(skillSearchResponse{
+ Results: []skillSearchResultItem{},
+ Limit: limit,
+ Offset: offset,
+ HasMore: false,
+ })
+ return
+ }
+
+ registryMgr := newSkillsRegistryManager(cfg)
+ searchLimit := offset + limit + 1
+ if searchLimit > maxRegistrySearchFanout {
+ searchLimit = maxRegistrySearchFanout
+ }
+ results, err := registryMgr.SearchAll(r.Context(), query, searchLimit)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to search skills: %v", err), http.StatusBadGateway)
+ return
+ }
+
+ if offset > len(results) {
+ offset = len(results)
+ }
+
+ end := offset + limit
+ if end > len(results) {
+ end = len(results)
+ }
+
+ pageResults := results[offset:end]
+ response := make([]skillSearchResultItem, 0, len(pageResults))
+ for _, result := range pageResults {
+ installedSkill, installed := installedSkills[result.Slug]
+ if !installed {
+ registry := registryMgr.GetRegistry(result.RegistryName)
+ if registry != nil {
+ dirName, err := registry.ResolveInstallDirName(result.Slug)
+ if err == nil {
+ installedSkill, installed = installedSkills[dirName]
+ }
+ }
+ }
+ item := skillSearchResultItem{
+ Score: result.Score,
+ Slug: result.Slug,
+ DisplayName: result.DisplayName,
+ Summary: result.Summary,
+ Version: result.Version,
+ RegistryName: result.RegistryName,
+ URL: registrySkillURL(cfg, result.RegistryName, result.Slug, result.Version),
+ Installed: installed,
+ }
+ if installed {
+ item.InstalledName = installedSkill.Name
+ }
+ response = append(response, item)
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ nextOffset := 0
+ hasMore := len(results) > end
+ if hasMore {
+ nextOffset = end
+ }
+ json.NewEncoder(w).Encode(skillSearchResponse{
+ Results: response,
+ Limit: limit,
+ Offset: offset,
+ NextOffset: nextOffset,
+ HasMore: hasMore,
+ })
+}
+
+func (h *Handler) handleInstallSkill(w http.ResponseWriter, r *http.Request) {
+ cfg, loadErr := config.LoadConfig(h.configPath)
+ if loadErr != nil {
+ http.Error(w, fmt.Sprintf("Failed to load config: %v", loadErr), http.StatusInternalServerError)
+ return
+ }
+ if registryErr := ensureSkillRegistryToolEnabled(cfg, "install_skill"); registryErr != nil {
+ http.Error(w, registryErr.Error(), http.StatusBadRequest)
+ return
+ }
+
+ var req installSkillRequest
+ if decodeErr := json.NewDecoder(r.Body).Decode(&req); decodeErr != nil {
+ http.Error(w, fmt.Sprintf("Invalid JSON: %v", decodeErr), http.StatusBadRequest)
+ return
+ }
+
+ req.Slug = strings.TrimSpace(req.Slug)
+ req.Registry = strings.TrimSpace(req.Registry)
+ req.Version = strings.TrimSpace(req.Version)
+ if req.Registry == "" {
+ req.Registry = defaultInstallSkillRegistry
+ }
+
+ if validateErr := utils.ValidateSkillIdentifier(req.Registry); validateErr != nil {
+ http.Error(
+ w,
+ fmt.Sprintf("invalid registry %q: error: %s", req.Registry, validateErr.Error()),
+ http.StatusBadRequest,
+ )
+ return
+ }
+
+ registryMgr := newSkillsRegistryManager(cfg)
+ registry := registryMgr.GetRegistry(req.Registry)
+ if registry == nil {
+ http.Error(w, fmt.Sprintf("registry %q not found", req.Registry), http.StatusBadRequest)
+ return
+ }
+ dirName, err := registry.ResolveInstallDirName(req.Slug)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("invalid slug %q: error: %s", req.Slug, err.Error()), http.StatusBadRequest)
+ return
+ }
+
+ workspace := cfg.WorkspacePath()
+ skillsRoot := filepath.Join(workspace, "skills")
+ targetDir := filepath.Join(workspace, "skills", dirName)
+ workspaceSkillWriteMu.Lock()
+ defer workspaceSkillWriteMu.Unlock()
+
+ targetExists := false
+ if _, statErr := os.Stat(targetDir); statErr == nil {
+ targetExists = true
+ } else if !os.IsNotExist(statErr) {
+ http.Error(w, fmt.Sprintf("Failed to inspect install target: %v", statErr), http.StatusInternalServerError)
+ return
+ }
+
+ if !req.Force && targetExists {
+ http.Error(w, fmt.Sprintf("skill %q already installed at %s", dirName, targetDir), http.StatusConflict)
+ return
+ }
+ if mkdirErr := os.MkdirAll(skillsRoot, 0o755); mkdirErr != nil {
+ http.Error(w, fmt.Sprintf("Failed to create skills directory: %v", mkdirErr), http.StatusInternalServerError)
+ return
+ }
+
+ stagedWorkspaceRoot, stagedTargetDir, err := createStagedSkillInstall(skillsRoot, dirName)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to prepare staged install: %v", err), http.StatusInternalServerError)
+ return
+ }
+ defer os.RemoveAll(stagedWorkspaceRoot)
+
+ result, err := registry.DownloadAndInstall(r.Context(), req.Slug, req.Version, stagedTargetDir)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to install skill: %v", err), http.StatusBadGateway)
+ return
+ }
+ if result.IsMalwareBlocked {
+ http.Error(
+ w,
+ fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", req.Slug),
+ http.StatusForbidden,
+ )
+ return
+ }
+
+ if findWorkspaceSkillInfoByDirectory(stagedWorkspaceRoot, dirName) == nil {
+ http.Error(
+ w,
+ fmt.Sprintf("Failed to install skill: registry archive for %q is not a valid skill", req.Slug),
+ http.StatusBadGateway,
+ )
+ return
+ }
+
+ installedAt := time.Now().UnixMilli()
+ normalizedSlug, registryURL := skills.BuildInstallMetadataForRegistryInstance(registry, req.Slug, result.Version)
+ if err := persistSkillOriginMeta(stagedTargetDir, installedSkillOriginMeta{
+ Version: 1,
+ OriginKind: "third_party",
+ Registry: registry.Name(),
+ Slug: normalizedSlug,
+ RegistryURL: registryURL,
+ InstalledVersion: result.Version,
+ InstalledAt: installedAt,
+ }); err != nil {
+ http.Error(w, fmt.Sprintf("Failed to persist skill metadata: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ if err := commitStagedSkillInstall(
+ stagedWorkspaceRoot,
+ stagedTargetDir,
+ targetDir,
+ req.Force && targetExists,
+ ); err != nil {
+ http.Error(w, fmt.Sprintf("Failed to activate installed skill: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ validatedSkill := findWorkspaceSkillByDirectory(cfg, dirName)
+ if validatedSkill == nil {
+ http.Error(
+ w,
+ fmt.Sprintf("Failed to install skill: activated archive for %q is not a valid skill", req.Slug),
+ http.StatusBadGateway,
+ )
+ return
+ }
+
+ installedSkill := &skillSupportItem{
+ Name: validatedSkill.Name,
+ Path: validatedSkill.Path,
+ Source: validatedSkill.Source,
+ Description: validatedSkill.Description,
+ OriginKind: "third_party",
+ RegistryName: registry.Name(),
+ RegistryURL: registryURL,
+ InstalledVersion: result.Version,
+ InstalledAt: installedAt,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(installSkillResponse{
+ Status: "ok",
+ Slug: req.Slug,
+ Registry: registry.Name(),
+ Version: result.Version,
+ Summary: result.Summary,
+ IsSuspicious: result.IsSuspicious,
+ InstalledSkill: installedSkill,
+ })
+}
+
func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
@@ -110,54 +459,26 @@ func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) {
}
defer uploadedFile.Close()
- content, err := io.ReadAll(io.LimitReader(uploadedFile, (1<<20)+1))
+ content, err := io.ReadAll(io.LimitReader(uploadedFile, maxImportedSkillSize+1))
if err != nil {
http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusBadRequest)
return
}
- if len(content) > 1<<20 {
+ if len(content) > maxImportedSkillSize {
http.Error(w, "file exceeds 1MB limit", http.StatusBadRequest)
return
}
+ workspaceSkillWriteMu.Lock()
+ defer workspaceSkillWriteMu.Unlock()
- skillName, err := normalizeImportedSkillName(fileHeader.Filename, content)
+ importedSkill, statusCode, err := importUploadedSkill(cfg, fileHeader.Filename, content)
if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
+ http.Error(w, err.Error(), statusCode)
return
}
- content = normalizeImportedSkillContent(content, skillName)
-
- workspace := cfg.WorkspacePath()
- skillDir := filepath.Join(workspace, "skills", skillName)
- skillFile := filepath.Join(skillDir, "SKILL.md")
- if _, err := os.Stat(skillDir); err == nil {
- http.Error(w, "skill already exists", http.StatusConflict)
- return
- }
-
- if err := os.MkdirAll(skillDir, 0o755); err != nil {
- http.Error(w, fmt.Sprintf("Failed to create skill directory: %v", err), http.StatusInternalServerError)
- return
- }
- if err := os.WriteFile(skillFile, content, 0o644); err != nil {
- http.Error(w, fmt.Sprintf("Failed to save skill: %v", err), http.StatusInternalServerError)
- return
- }
-
- loader := newSkillsLoader(workspace)
- for _, skill := range loader.ListSkills() {
- if skill.Path == skillFile || (skill.Name == skillName && skill.Source == "workspace") {
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(skill)
- return
- }
- }
w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]string{
- "name": skillName,
- "path": skillFile,
- })
+ json.NewEncoder(w).Encode(importedSkill)
}
func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {
@@ -169,13 +490,17 @@ func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {
loader := newSkillsLoader(cfg.WorkspacePath())
name := r.PathValue("name")
+ workspaceSkillWriteMu.Lock()
+ defer workspaceSkillWriteMu.Unlock()
+
+ var matchedNonWorkspace bool
for _, skill := range loader.ListSkills() {
if skill.Name != name {
continue
}
if skill.Source != "workspace" {
- http.Error(w, "only workspace skills can be deleted", http.StatusBadRequest)
- return
+ matchedNonWorkspace = true
+ continue
}
if err := os.RemoveAll(filepath.Dir(skill.Path)); err != nil {
http.Error(w, fmt.Sprintf("Failed to delete skill: %v", err), http.StatusInternalServerError)
@@ -185,6 +510,10 @@ func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
return
}
+ if matchedNonWorkspace {
+ http.Error(w, "only workspace skills can be deleted", http.StatusBadRequest)
+ return
+ }
http.Error(w, "Skill not found", http.StatusNotFound)
}
@@ -197,12 +526,263 @@ func newSkillsLoader(workspace string) *skills.SkillsLoader {
)
}
+func newSkillsRegistryManager(cfg *config.Config) *skills.RegistryManager {
+ return skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills)
+}
+
+func ensureSkillRegistryToolEnabled(cfg *config.Config, toolName string) error {
+ if !cfg.Tools.IsToolEnabled("skills") {
+ return fmt.Errorf("tools.skills is disabled")
+ }
+ if !cfg.Tools.IsToolEnabled(toolName) {
+ return fmt.Errorf("%s is disabled", toolName)
+ }
+ return nil
+}
+
+func buildSkillSupportItems(cfg *config.Config) ([]skillSupportItem, error) {
+ rawSkills := newSkillsLoader(cfg.WorkspacePath()).ListSkills()
+ items := make([]skillSupportItem, 0, len(rawSkills))
+ for _, skill := range rawSkills {
+ item, err := enrichSkillInfo(cfg, skill)
+ if err != nil {
+ return nil, err
+ }
+ items = append(items, item)
+ }
+ return items, nil
+}
+
+func buildWorkspaceSkillItemsByDirectory(cfg *config.Config) (map[string]skillSupportItem, error) {
+ result := make(map[string]skillSupportItem)
+ items, err := buildSkillSupportItems(cfg)
+ if err != nil {
+ return nil, err
+ }
+ for _, skill := range items {
+ if skill.Source != "workspace" {
+ continue
+ }
+ dir := filepath.Base(filepath.Dir(skill.Path))
+ if dir == "" {
+ continue
+ }
+ result[dir] = skill
+ }
+ return result, nil
+}
+
+func buildOccupiedWorkspaceSkillsByDirectory(cfg *config.Config) (map[string]skillSupportItem, error) {
+ result := make(map[string]skillSupportItem)
+ items, err := buildSkillSupportItems(cfg)
+ if err != nil {
+ return nil, err
+ }
+ for _, skill := range items {
+ if skill.Source != "workspace" {
+ continue
+ }
+
+ dirName := filepath.Base(filepath.Dir(skill.Path))
+ if dirName != "" {
+ result[dirName] = skill
+ }
+ if meta, err := readInstalledSkillOriginMeta(skill.Path); err == nil && meta != nil && meta.Slug != "" {
+ key := skills.NormalizeInstallTargetForRegistry(cfg.Tools.Skills, meta.Registry, meta.Slug)
+ if key == "" {
+ key = meta.Slug
+ }
+ if key != "" {
+ result[key] = skill
+ }
+ }
+ }
+ return result, nil
+}
+
+func findWorkspaceSkillByDirectory(cfg *config.Config, directory string) *skillSupportItem {
+ items, err := buildWorkspaceSkillItemsByDirectory(cfg)
+ if err != nil {
+ return nil
+ }
+ skill, ok := items[directory]
+ if !ok {
+ return nil
+ }
+ return &skill
+}
+
+func findWorkspaceSkillInfoByDirectory(workspace, directory string) *skills.SkillInfo {
+ loader := skills.NewSkillsLoader(workspace, "", "")
+ for _, skill := range loader.ListSkills() {
+ if skill.Source != "workspace" {
+ continue
+ }
+ if filepath.Base(filepath.Dir(skill.Path)) != directory {
+ continue
+ }
+ skillCopy := skill
+ return &skillCopy
+ }
+ return nil
+}
+
+func createStagedSkillInstall(skillsRoot, slug string) (string, string, error) {
+ stagedWorkspaceRoot, err := os.MkdirTemp(skillsRoot, "."+slug+"-install-*")
+ if err != nil {
+ return "", "", err
+ }
+ stagedTargetDir := filepath.Join(stagedWorkspaceRoot, "skills", slug)
+ return stagedWorkspaceRoot, stagedTargetDir, nil
+}
+
+func commitStagedSkillInstall(stagedWorkspaceRoot, stagedTargetDir, targetDir string, replaceExisting bool) error {
+ if !replaceExisting {
+ return os.Rename(stagedTargetDir, targetDir)
+ }
+
+ backupDir, err := reserveTempDirPath(filepath.Dir(targetDir), "."+filepath.Base(targetDir)+"-backup-*")
+ if err != nil {
+ return err
+ }
+
+ if err := os.Rename(targetDir, backupDir); err != nil {
+ return fmt.Errorf("failed to move existing skill aside: %w", err)
+ }
+
+ if err := os.Rename(stagedTargetDir, targetDir); err != nil {
+ if rollbackErr := os.Rename(backupDir, targetDir); rollbackErr != nil {
+ return fmt.Errorf("failed to activate replacement: %w (rollback failed: %v)", err, rollbackErr)
+ }
+ return fmt.Errorf("failed to activate replacement: %w", err)
+ }
+
+ _ = os.RemoveAll(backupDir)
+ _ = os.RemoveAll(stagedWorkspaceRoot)
+ return nil
+}
+
+func reserveTempDirPath(parent, pattern string) (string, error) {
+ tempDir, err := os.MkdirTemp(parent, pattern)
+ if err != nil {
+ return "", err
+ }
+ if err := os.Remove(tempDir); err != nil {
+ return "", err
+ }
+ return tempDir, nil
+}
+
+func enrichSkillInfo(cfg *config.Config, skill skills.SkillInfo) (skillSupportItem, error) {
+ item := skillSupportItem{
+ Name: skill.Name,
+ Path: skill.Path,
+ Source: skill.Source,
+ Description: skill.Description,
+ OriginKind: "builtin",
+ }
+
+ switch skill.Source {
+ case "builtin":
+ item.OriginKind = "builtin"
+ case "global":
+ item.OriginKind = "builtin"
+ case "workspace":
+ meta, err := readInstalledSkillOriginMeta(skill.Path)
+ if err == nil && meta != nil {
+ switch meta.OriginKind {
+ case "manual":
+ item.OriginKind = "manual"
+ item.InstalledAt = meta.InstalledAt
+ case "third_party":
+ item.OriginKind = "third_party"
+ item.RegistryName = meta.Registry
+ item.RegistryURL = registrySkillURLFromMeta(cfg, meta)
+ item.InstalledVersion = meta.InstalledVersion
+ item.InstalledAt = meta.InstalledAt
+ default:
+ if meta.Registry != "" || meta.Slug != "" || meta.InstalledVersion != "" {
+ item.OriginKind = "third_party"
+ item.RegistryName = meta.Registry
+ item.RegistryURL = registrySkillURLFromMeta(cfg, meta)
+ item.InstalledVersion = meta.InstalledVersion
+ item.InstalledAt = meta.InstalledAt
+ } else {
+ item.OriginKind = "builtin"
+ item.InstalledAt = meta.InstalledAt
+ }
+ }
+ } else {
+ item.OriginKind = "builtin"
+ }
+ default:
+ item.OriginKind = "builtin"
+ }
+
+ return item, nil
+}
+
+func readInstalledSkillOriginMeta(skillPath string) (*installedSkillOriginMeta, error) {
+ metaPath := filepath.Join(filepath.Dir(skillPath), ".skill-origin.json")
+ data, err := os.ReadFile(metaPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ var meta installedSkillOriginMeta
+ if err := json.Unmarshal(data, &meta); err != nil {
+ return nil, err
+ }
+ return &meta, nil
+}
+
+func writeSkillOriginMeta(targetDir string, meta installedSkillOriginMeta) error {
+ data, err := json.MarshalIndent(meta, "", " ")
+ if err != nil {
+ return err
+ }
+ return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
+}
+
+func registrySkillURL(cfg *config.Config, registryName, slug, version string) string {
+ if cfg == nil || registryName == "" || slug == "" {
+ return ""
+ }
+ registry := skills.LookupRegistryFromToolsConfig(cfg.Tools.Skills, registryName)
+ if registry == nil {
+ return ""
+ }
+ return registry.SkillURL(slug, version)
+}
+
+func registrySkillURLFromMeta(cfg *config.Config, meta *installedSkillOriginMeta) string {
+ if meta == nil || meta.Slug == "" {
+ return ""
+ }
+ if meta.RegistryURL != "" {
+ return meta.RegistryURL
+ }
+ if cfg == nil || meta.Registry == "" {
+ return ""
+ }
+ return registrySkillURL(cfg, meta.Registry, meta.Slug, meta.InstalledVersion)
+}
+
func normalizeImportedSkillName(filename string, content []byte) (string, error) {
+ return normalizeImportedSkillNameWithHint(filename, "", content)
+}
+
+func normalizeImportedSkillNameWithHint(filename, directoryHint string, content []byte) (string, error) {
rawContent := strings.ReplaceAll(string(content), "\r\n", "\n")
rawContent = strings.ReplaceAll(rawContent, "\r", "\n")
metadata, _ := extractImportedSkillMetadata(rawContent)
raw := strings.TrimSpace(metadata["name"])
+ if raw == "" {
+ raw = strings.TrimSpace(directoryHint)
+ }
if raw == "" {
raw = strings.TrimSpace(strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)))
}
@@ -259,6 +839,210 @@ func normalizeImportedSkillContent(content []byte, skillName string) []byte {
return []byte(builder.String())
}
+func importUploadedSkill(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) {
+ if isImportedSkillArchive(filename, content) {
+ return importUploadedSkillArchive(cfg, filename, content)
+ }
+ return importUploadedMarkdownSkill(cfg, filename, content)
+}
+
+func importUploadedMarkdownSkill(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) {
+ skillName, err := normalizeImportedSkillName(filename, content)
+ if err != nil {
+ return nil, http.StatusBadRequest, err
+ }
+
+ normalizedContent := normalizeImportedSkillContent(content, skillName)
+ workspace := cfg.WorkspacePath()
+ skillDir := filepath.Join(workspace, "skills", skillName)
+ skillFile := filepath.Join(skillDir, "SKILL.md")
+
+ if err := ensureWorkspaceSkillDoesNotExist(skillDir); err != nil {
+ return nil, statusCodeForImportedSkillWriteError(err), err
+ }
+ if err := os.MkdirAll(skillDir, 0o755); err != nil {
+ return nil, http.StatusInternalServerError, fmt.Errorf("Failed to create skill directory: %v", err)
+ }
+ if err := fileutil.WriteFileAtomic(skillFile, normalizedContent, 0o644); err != nil {
+ _ = os.RemoveAll(skillDir)
+ return nil, http.StatusInternalServerError, fmt.Errorf("Failed to save skill: %v", err)
+ }
+
+ return finalizeImportedSkill(cfg, skillDir, skillName, false)
+}
+
+func importUploadedSkillArchive(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) {
+ tmpDir, tempDirErr := os.MkdirTemp("", "picoclaw-skill-import-*")
+ if tempDirErr != nil {
+ return nil, http.StatusInternalServerError, fmt.Errorf("Failed to create temp directory: %v", tempDirErr)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ archivePath := filepath.Join(tmpDir, "import.zip")
+ if writeErr := fileutil.WriteFileAtomic(archivePath, content, 0o600); writeErr != nil {
+ return nil, http.StatusInternalServerError, fmt.Errorf("Failed to stage uploaded archive: %v", writeErr)
+ }
+
+ extractDir := filepath.Join(tmpDir, "extract")
+ if extractErr := utils.ExtractZipFile(archivePath, extractDir); extractErr != nil {
+ return nil, http.StatusBadRequest, fmt.Errorf("invalid ZIP archive: %w", extractErr)
+ }
+
+ skillRoot, err := findImportedSkillRoot(extractDir)
+ if err != nil {
+ return nil, http.StatusBadRequest, err
+ }
+
+ skillFile := filepath.Join(skillRoot, "SKILL.md")
+ skillContent, err := os.ReadFile(skillFile)
+ if err != nil {
+ return nil, http.StatusBadRequest, fmt.Errorf("failed to read SKILL.md from archive: %w", err)
+ }
+
+ directoryHint := ""
+ if filepath.Clean(skillRoot) != filepath.Clean(extractDir) {
+ directoryHint = filepath.Base(skillRoot)
+ }
+ skillName, err := normalizeImportedSkillNameWithHint(filename, directoryHint, skillContent)
+ if err != nil {
+ return nil, http.StatusBadRequest, err
+ }
+
+ workspace := cfg.WorkspacePath()
+ skillDir := filepath.Join(workspace, "skills", skillName)
+ if err := ensureWorkspaceSkillDoesNotExist(skillDir); err != nil {
+ return nil, statusCodeForImportedSkillWriteError(err), err
+ }
+ if err := copyImportedSkillTree(skillRoot, skillDir); err != nil {
+ _ = os.RemoveAll(skillDir)
+ return nil, http.StatusInternalServerError, fmt.Errorf("Failed to save skill: %v", err)
+ }
+
+ normalizedContent := normalizeImportedSkillContent(skillContent, skillName)
+ if err := fileutil.WriteFileAtomic(filepath.Join(skillDir, "SKILL.md"), normalizedContent, 0o644); err != nil {
+ _ = os.RemoveAll(skillDir)
+ return nil, http.StatusInternalServerError, fmt.Errorf("Failed to normalize skill: %v", err)
+ }
+
+ return finalizeImportedSkill(cfg, skillDir, skillName, true)
+}
+
+func isImportedSkillArchive(filename string, content []byte) bool {
+ if strings.EqualFold(filepath.Ext(filename), ".zip") {
+ return true
+ }
+ return len(content) >= 4 && bytes.HasPrefix(content, []byte("PK\x03\x04"))
+}
+
+func ensureWorkspaceSkillDoesNotExist(skillDir string) error {
+ if _, err := os.Stat(skillDir); err == nil {
+ return errImportedSkillExists
+ } else if !os.IsNotExist(err) {
+ return fmt.Errorf("failed to inspect skill directory: %w", err)
+ }
+ return nil
+}
+
+func statusCodeForImportedSkillWriteError(err error) int {
+ if err == nil {
+ return http.StatusOK
+ }
+ if errors.Is(err, errImportedSkillExists) {
+ return http.StatusConflict
+ }
+ return http.StatusInternalServerError
+}
+
+func finalizeImportedSkill(
+ cfg *config.Config,
+ skillDir string,
+ skillName string,
+ requireValidatedSkill bool,
+) (*skillSupportItem, int, error) {
+ if err := persistSkillOriginMeta(skillDir, installedSkillOriginMeta{
+ Version: 1,
+ OriginKind: "manual",
+ InstalledAt: time.Now().UnixMilli(),
+ }); err != nil {
+ _ = os.RemoveAll(skillDir)
+ return nil, http.StatusInternalServerError, fmt.Errorf("Failed to persist skill metadata: %v", err)
+ }
+
+ if importedSkill := findWorkspaceSkillByDirectory(cfg, skillName); importedSkill != nil {
+ return importedSkill, http.StatusOK, nil
+ }
+
+ if requireValidatedSkill {
+ _ = os.RemoveAll(skillDir)
+ return nil, http.StatusBadRequest, fmt.Errorf("imported archive is not a valid skill")
+ }
+
+ return &skillSupportItem{
+ Name: skillName,
+ Path: filepath.Join(skillDir, "SKILL.md"),
+ Source: "workspace",
+ Description: "Imported skill",
+ OriginKind: "manual",
+ }, http.StatusOK, nil
+}
+
+func findImportedSkillRoot(extractDir string) (string, error) {
+ skillFiles := make([]string, 0, 1)
+ err := filepath.WalkDir(extractDir, func(path string, d fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if d.IsDir() {
+ return nil
+ }
+ if d.Name() == "SKILL.md" {
+ skillFiles = append(skillFiles, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return "", fmt.Errorf("failed to inspect ZIP archive: %w", err)
+ }
+
+ switch len(skillFiles) {
+ case 0:
+ return "", fmt.Errorf("ZIP archive must contain a SKILL.md file")
+ case 1:
+ return filepath.Dir(skillFiles[0]), nil
+ default:
+ return "", fmt.Errorf("ZIP archive must contain exactly one SKILL.md file")
+ }
+}
+
+func copyImportedSkillTree(srcDir, destDir string) error {
+ return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+
+ relPath, err := filepath.Rel(srcDir, path)
+ if err != nil {
+ return err
+ }
+ if relPath == "." {
+ return os.MkdirAll(destDir, 0o755)
+ }
+
+ destPath := filepath.Join(destDir, relPath)
+ info, err := d.Info()
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return os.MkdirAll(destPath, 0o755)
+ }
+ if !info.Mode().IsRegular() {
+ return fmt.Errorf("archive contains unsupported file %q", relPath)
+ }
+ return fileutil.CopyFile(path, destPath, info.Mode().Perm())
+ })
+}
+
func extractImportedSkillMetadata(raw string) (map[string]string, string) {
matches := importedSkillFrontmatter.FindStringSubmatch(raw)
if len(matches) != 2 {
@@ -309,14 +1093,7 @@ func loadSkillContent(path string) (string, error) {
}
func globalConfigDir() string {
- if home := os.Getenv(config.EnvHome); home != "" {
- return home
- }
- home, err := os.UserHomeDir()
- if err != nil {
- return ""
- }
- return filepath.Join(home, ".picoclaw")
+ return config.GetHome()
}
func builtinSkillsDir() string {
diff --git a/web/backend/api/skills_test.go b/web/backend/api/skills_test.go
index 3289d5b33..977ec693f 100644
--- a/web/backend/api/skills_test.go
+++ b/web/backend/api/skills_test.go
@@ -1,19 +1,40 @@
package api
import (
+ "archive/zip"
"bytes"
"encoding/json"
+ "errors"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
+ "strconv"
"testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
"github.com/sipeed/picoclaw/pkg/config"
)
+func setClawHubBaseURL(cfg *config.Config, baseURL string) {
+ registryCfg, _ := cfg.Tools.Skills.Registries.Get("clawhub")
+ registryCfg.BaseURL = baseURL
+ cfg.Tools.Skills.Registries.Set("clawhub", registryCfg)
+}
+
+func setGithubBaseURL(cfg *config.Config, baseURL string) {
+ registryCfg, ok := cfg.Tools.Skills.Registries.Get("github")
+ if !ok {
+ return
+ }
+ registryCfg.BaseURL = baseURL
+ cfg.Tools.Skills.Registries.Set("github", registryCfg)
+}
+
func TestHandleListSkills(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -99,8 +120,10 @@ func TestHandleListSkills(t *testing.T) {
}
gotSkills := make(map[string]string, len(resp.Skills))
+ gotOriginKinds := make(map[string]string, len(resp.Skills))
for _, skill := range resp.Skills {
gotSkills[skill.Name] = skill.Source
+ gotOriginKinds[skill.Name] = skill.OriginKind
}
if gotSkills["workspace-skill"] != "workspace" {
t.Fatalf("workspace-skill source = %q, want workspace", gotSkills["workspace-skill"])
@@ -111,6 +134,15 @@ func TestHandleListSkills(t *testing.T) {
if gotSkills["builtin-skill"] != "builtin" {
t.Fatalf("builtin-skill source = %q, want builtin", gotSkills["builtin-skill"])
}
+ if gotOriginKinds["workspace-skill"] != "builtin" {
+ t.Fatalf("workspace-skill origin_kind = %q, want builtin", gotOriginKinds["workspace-skill"])
+ }
+ if gotOriginKinds["global-skill"] != "builtin" {
+ t.Fatalf("global-skill origin_kind = %q, want builtin", gotOriginKinds["global-skill"])
+ }
+ if gotOriginKinds["builtin-skill"] != "builtin" {
+ t.Fatalf("builtin-skill origin_kind = %q, want builtin", gotOriginKinds["builtin-skill"])
+ }
}
func TestHandleGetSkill(t *testing.T) {
@@ -162,6 +194,9 @@ func TestHandleGetSkill(t *testing.T) {
if resp.Name != "viewer-skill" || resp.Source != "workspace" || resp.Description != "Viewable skill" {
t.Fatalf("unexpected response: %#v", resp)
}
+ if resp.OriginKind != "builtin" {
+ t.Fatalf("resp.OriginKind = %q, want builtin", resp.OriginKind)
+ }
if resp.Content != "# Viewer Skill\n\nThis is visible content.\n" {
t.Fatalf("content = %q", resp.Content)
}
@@ -271,6 +306,17 @@ func TestHandleImportSkill(t *testing.T) {
if string(content) != expected {
t.Fatalf("saved skill content mismatch:\n%s", string(content))
}
+ metaContent, err := os.ReadFile(filepath.Join(workspace, "skills", "plain-skill", ".skill-origin.json"))
+ if err != nil {
+ t.Fatalf("ReadFile(origin metadata) error = %v", err)
+ }
+ var originMeta installedSkillOriginMeta
+ if err := json.Unmarshal(metaContent, &originMeta); err != nil {
+ t.Fatalf("Unmarshal(origin metadata) error = %v", err)
+ }
+ if originMeta.OriginKind != "manual" {
+ t.Fatalf("originMeta.OriginKind = %q, want manual", originMeta.OriginKind)
+ }
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/api/skills", nil)
@@ -293,6 +339,174 @@ func TestHandleImportSkill(t *testing.T) {
}
}
+func TestHandleImportSkillZip(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ zipContent := buildSkillZip(t, map[string]string{
+ "Wrapped Skill/SKILL.md": "---\nname: wrapped-skill\ndescription: Wrapped skill\n---\n# Wrapped Skill\n\nUse this skill from zip.\n",
+ "Wrapped Skill/docs/README.md": "# Extra file\n",
+ })
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ part, createErr := writer.CreateFormFile("file", "Wrapped Skill.zip")
+ if createErr != nil {
+ t.Fatalf("CreateFormFile() error = %v", createErr)
+ }
+ if _, writeErr := part.Write(zipContent); writeErr != nil {
+ t.Fatalf("Write(zipContent) error = %v", writeErr)
+ }
+ if closeErr := writer.Close(); closeErr != nil {
+ t.Fatalf("Close() error = %v", closeErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ skillDir := filepath.Join(workspace, "skills", "wrapped-skill")
+ skillFile := filepath.Join(skillDir, "SKILL.md")
+ content, err := os.ReadFile(skillFile)
+ if err != nil {
+ t.Fatalf("ReadFile() error = %v", err)
+ }
+ expected := "---\nname: wrapped-skill\ndescription: Wrapped skill\n---\n\n# Wrapped Skill\n\nUse this skill from zip.\n"
+ if string(content) != expected {
+ t.Fatalf("saved skill content mismatch:\n%s", string(content))
+ }
+
+ extraFile := filepath.Join(skillDir, "docs", "README.md")
+ extraContent, err := os.ReadFile(extraFile)
+ if err != nil {
+ t.Fatalf("ReadFile(extra file) error = %v", err)
+ }
+ if string(extraContent) != "# Extra file\n" {
+ t.Fatalf("extra file content = %q", string(extraContent))
+ }
+}
+
+func TestHandleImportSkillZipRejectsArchiveWithoutSkill(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ zipContent := buildSkillZip(t, map[string]string{
+ "README.md": "# Not a skill\n",
+ })
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ part, err := writer.CreateFormFile("file", "invalid.zip")
+ if err != nil {
+ t.Fatalf("CreateFormFile() error = %v", err)
+ }
+ if _, err := part.Write(zipContent); err != nil {
+ t.Fatalf("Write(zipContent) error = %v", err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatalf("Close() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
+ }
+ if _, err := os.Stat(filepath.Join(workspace, "skills", "invalid")); !os.IsNotExist(err) {
+ t.Fatalf("invalid archive should not leave behind a skill dir, stat err=%v", err)
+ }
+}
+
+func TestHandleImportSkillRollsBackOnOriginMetadataWriteFailure(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ previousPersist := persistSkillOriginMeta
+ persistSkillOriginMeta = func(targetDir string, meta installedSkillOriginMeta) error {
+ return errors.New("forced metadata failure")
+ }
+ defer func() {
+ persistSkillOriginMeta = previousPersist
+ }()
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ part, err := writer.CreateFormFile("file", "Rollback Skill.md")
+ if err != nil {
+ t.Fatalf("CreateFormFile() error = %v", err)
+ }
+ if _, err := io.WriteString(part, "# Rollback Skill\n"); err != nil {
+ t.Fatalf("WriteString() error = %v", err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatalf("Close() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusInternalServerError, rec.Body.String())
+ }
+
+ skillDir := filepath.Join(workspace, "skills", "rollback-skill")
+ if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
+ t.Fatalf("skill directory should be removed after metadata write failure, stat err=%v", err)
+ }
+}
+
func TestHandleDeleteSkill(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -334,3 +548,1316 @@ func TestHandleDeleteSkill(t *testing.T) {
t.Fatalf("skill directory should be removed, stat err=%v", err)
}
}
+
+func TestHandleDeleteSkillPrefersWorkspaceMatch(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ homeDir := t.TempDir()
+ t.Setenv(config.EnvHome, homeDir)
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ workspaceSkillDir := filepath.Join(workspace, "skills", "delete-me-workspace")
+ if err := os.MkdirAll(workspaceSkillDir, 0o755); err != nil {
+ t.Fatalf("MkdirAll(workspace) error = %v", err)
+ }
+ if err := os.WriteFile(
+ filepath.Join(workspaceSkillDir, "SKILL.md"),
+ []byte("---\nname: delete-me\ndescription: workspace delete me\n---\n"),
+ 0o644,
+ ); err != nil {
+ t.Fatalf("WriteFile(workspace) error = %v", err)
+ }
+
+ globalSkillDir := filepath.Join(homeDir, "skills", "delete-me-global")
+ if err := os.MkdirAll(globalSkillDir, 0o755); err != nil {
+ t.Fatalf("MkdirAll(global) error = %v", err)
+ }
+ if err := os.WriteFile(
+ filepath.Join(globalSkillDir, "SKILL.md"),
+ []byte("---\nname: delete-me\ndescription: global delete me\n---\n"),
+ 0o644,
+ ); err != nil {
+ t.Fatalf("WriteFile(global) error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodDelete, "/api/skills/delete-me", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if _, err := os.Stat(workspaceSkillDir); !os.IsNotExist(err) {
+ t.Fatalf("workspace skill directory should be removed, stat err=%v", err)
+ }
+ if _, err := os.Stat(globalSkillDir); err != nil {
+ t.Fatalf("global skill directory should remain, stat err=%v", err)
+ }
+}
+
+func TestHandleSearchSkills(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ if err := os.MkdirAll(filepath.Join(workspace, "skills", "github"), 0o755); err != nil {
+ t.Fatalf("MkdirAll() error = %v", err)
+ }
+ if err := os.WriteFile(
+ filepath.Join(workspace, "skills", "github", "SKILL.md"),
+ []byte("---\nname: github\ndescription: Installed GitHub skill\n---\n# GitHub\n"),
+ 0o644,
+ ); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/search" {
+ http.NotFound(w, r)
+ return
+ }
+ if got := r.URL.Query().Get("q"); got != "github" {
+ t.Fatalf("query = %q, want github", got)
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "results": []map[string]any{
+ {
+ "score": 0.95,
+ "slug": "github",
+ "displayName": "GitHub",
+ "summary": "GitHub integration skill",
+ "version": "1.2.3",
+ },
+ {
+ "score": 0.87,
+ "slug": "jira",
+ "displayName": "Jira",
+ "summary": "Issue tracker skill",
+ "version": "0.9.0",
+ },
+ },
+ })
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=5", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp skillSearchResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if resp.Limit != 5 {
+ t.Fatalf("limit = %d, want 5", resp.Limit)
+ }
+ if resp.Offset != 0 {
+ t.Fatalf("offset = %d, want 0", resp.Offset)
+ }
+ if resp.HasMore {
+ t.Fatalf("has_more = true, want false")
+ }
+ if len(resp.Results) != 2 {
+ t.Fatalf("results count = %d, want 2", len(resp.Results))
+ }
+ if resp.Results[0].URL != server.URL+"/skills/github" {
+ t.Fatalf("first result URL = %q, want %q", resp.Results[0].URL, server.URL+"/skills/github")
+ }
+ if !resp.Results[0].Installed || resp.Results[0].InstalledName != "github" {
+ t.Fatalf("first result should be treated as occupying the workspace slug, got %#v", resp.Results[0])
+ }
+ if resp.Results[1].Installed {
+ t.Fatalf("second result should not be installed, got %#v", resp.Results[1])
+ }
+}
+
+func TestHandleSearchSkillsUsesGitHubResultVersionInURL(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v3/search/code" {
+ http.NotFound(w, r)
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "path": "skills/pr-review/SKILL.md",
+ "score": 10,
+ "repository": map[string]any{
+ "full_name": "foo/bar",
+ "name": "bar",
+ "description": "Review pull requests",
+ "default_branch": "master",
+ },
+ },
+ },
+ })
+ }))
+ defer server.Close()
+
+ setGithubBaseURL(cfg, server.URL)
+ clawHubRegistry, _ := cfg.Tools.Skills.Registries.Get("clawhub")
+ clawHubRegistry.Enabled = false
+ cfg.Tools.Skills.Registries.Set("clawhub", clawHubRegistry)
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp skillSearchResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Results) != 1 {
+ t.Fatalf("results count = %d, want 1", len(resp.Results))
+ }
+ if resp.Results[0].URL != server.URL+"/foo/bar/tree/master/skills/pr-review" {
+ t.Fatalf("result URL = %q", resp.Results[0].URL)
+ }
+}
+
+func TestHandleSearchSkillsGitHubRateLimitDegradesGracefully(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v3/search/code" {
+ http.NotFound(w, r)
+ return
+ }
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte(`{"message":"API rate limit exceeded for 1.2.3.4"}`))
+ }))
+ defer server.Close()
+
+ setGithubBaseURL(cfg, server.URL)
+ clawHubRegistry, _ := cfg.Tools.Skills.Registries.Get("clawhub")
+ clawHubRegistry.Enabled = false
+ cfg.Tools.Skills.Registries.Set("clawhub", clawHubRegistry)
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp skillSearchResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Results) != 0 {
+ t.Fatalf("results count = %d, want 0", len(resp.Results))
+ }
+}
+
+func TestHandleSearchSkillsPagination(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/search" {
+ http.NotFound(w, r)
+ return
+ }
+ if got := r.URL.Query().Get("limit"); got != "5" {
+ t.Fatalf("limit = %q, want 5", got)
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "results": []map[string]any{
+ {
+ "score": 0.99,
+ "slug": "skill-1",
+ "displayName": "Skill 1",
+ "summary": "Summary 1",
+ "version": "1.0.0",
+ },
+ {
+ "score": 0.98,
+ "slug": "skill-2",
+ "displayName": "Skill 2",
+ "summary": "Summary 2",
+ "version": "1.0.0",
+ },
+ {
+ "score": 0.97,
+ "slug": "skill-3",
+ "displayName": "Skill 3",
+ "summary": "Summary 3",
+ "version": "1.0.0",
+ },
+ {
+ "score": 0.96,
+ "slug": "skill-4",
+ "displayName": "Skill 4",
+ "summary": "Summary 4",
+ "version": "1.0.0",
+ },
+ },
+ })
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=2&offset=2", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp skillSearchResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if resp.Limit != 2 {
+ t.Fatalf("limit = %d, want 2", resp.Limit)
+ }
+ if resp.Offset != 2 {
+ t.Fatalf("offset = %d, want 2", resp.Offset)
+ }
+ if resp.HasMore {
+ t.Fatalf("has_more = true, want false")
+ }
+ if len(resp.Results) != 2 {
+ t.Fatalf("results count = %d, want 2", len(resp.Results))
+ }
+ if resp.Results[0].Slug != "skill-3" || resp.Results[1].Slug != "skill-4" {
+ t.Fatalf("unexpected paged results: %#v", resp.Results)
+ }
+ if resp.NextOffset != 0 {
+ t.Fatalf("next_offset = %d, want 0", resp.NextOffset)
+ }
+}
+
+func TestHandleSearchSkillsClampsRegistryFanout(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/search" {
+ http.NotFound(w, r)
+ return
+ }
+ if got := r.URL.Query().Get("limit"); got != strconv.Itoa(maxRegistrySearchFanout) {
+ t.Fatalf("limit = %q, want %d", got, maxRegistrySearchFanout)
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "results": []map[string]any{
+ {
+ "score": 0.99,
+ "slug": "skill-1",
+ "displayName": "Skill 1",
+ "summary": "Summary 1",
+ "version": "1.0.0",
+ },
+ },
+ })
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=20&offset=100000", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp skillSearchResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Results) != 0 {
+ t.Fatalf("results count = %d, want 0", len(resp.Results))
+ }
+}
+
+func TestHandleInstallSkill(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ zipContent := buildSkillZip(t, map[string]string{
+ "SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n\nUse this skill.\n",
+ })
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/search":
+ json.NewEncoder(w).Encode(map[string]any{
+ "results": []map[string]any{
+ {
+ "score": 0.95,
+ "slug": "github",
+ "displayName": "GitHub",
+ "summary": "GitHub registry skill",
+ "version": "1.2.3",
+ },
+ },
+ })
+ case "/api/v1/skills/github":
+ json.NewEncoder(w).Encode(map[string]any{
+ "slug": "github",
+ "displayName": "GitHub",
+ "summary": "GitHub registry skill",
+ "latestVersion": map[string]any{
+ "version": "1.2.3",
+ },
+ "moderation": map[string]any{
+ "isMalwareBlocked": false,
+ "isSuspicious": false,
+ },
+ })
+ case "/api/v1/download":
+ if got := r.URL.Query().Get("slug"); got != "github" {
+ t.Fatalf("slug = %q, want github", got)
+ }
+ if got := r.URL.Query().Get("version"); got != "1.2.3" {
+ t.Fatalf("version = %q, want 1.2.3", got)
+ }
+ w.Header().Set("Content-Type", "application/zip")
+ _, _ = w.Write(zipContent)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ body, err := json.Marshal(installSkillRequest{
+ Slug: "github",
+ Registry: "clawhub",
+ })
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp installSkillResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if resp.Status != "ok" || resp.Version != "1.2.3" || resp.InstalledSkill == nil {
+ t.Fatalf("unexpected response: %#v", resp)
+ }
+ if resp.InstalledSkill.OriginKind != "third_party" {
+ t.Fatalf("resp.InstalledSkill.OriginKind = %q, want third_party", resp.InstalledSkill.OriginKind)
+ }
+ if resp.InstalledSkill.RegistryURL != server.URL+"/skills/github" {
+ t.Fatalf(
+ "resp.InstalledSkill.RegistryURL = %q, want %q",
+ resp.InstalledSkill.RegistryURL,
+ server.URL+"/skills/github",
+ )
+ }
+
+ skillFile := filepath.Join(workspace, "skills", "github", "SKILL.md")
+ if _, err := os.Stat(skillFile); err != nil {
+ t.Fatalf("installed skill file missing: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(workspace, "skills", "github", ".skill-origin.json")); err != nil {
+ t.Fatalf("origin metadata missing: %v", err)
+ }
+
+ detailRec := httptest.NewRecorder()
+ detailReq := httptest.NewRequest(http.MethodGet, "/api/skills/github", nil)
+ mux.ServeHTTP(detailRec, detailReq)
+
+ if detailRec.Code != http.StatusOK {
+ t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusOK, detailRec.Body.String())
+ }
+
+ var detailResp skillDetailResponse
+ if err := json.Unmarshal(detailRec.Body.Bytes(), &detailResp); err != nil {
+ t.Fatalf("Unmarshal(detail response) error = %v", err)
+ }
+ if detailResp.RegistryURL != server.URL+"/skills/github" {
+ t.Fatalf("detailResp.RegistryURL = %q, want %q", detailResp.RegistryURL, server.URL+"/skills/github")
+ }
+
+ searchRec := httptest.NewRecorder()
+ searchReq := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=5", nil)
+ mux.ServeHTTP(searchRec, searchReq)
+
+ if searchRec.Code != http.StatusOK {
+ t.Fatalf("search status = %d, want %d, body=%s", searchRec.Code, http.StatusOK, searchRec.Body.String())
+ }
+
+ var searchResp skillSearchResponse
+ if err := json.Unmarshal(searchRec.Body.Bytes(), &searchResp); err != nil {
+ t.Fatalf("Unmarshal(search response) error = %v", err)
+ }
+ if len(searchResp.Results) != 1 {
+ t.Fatalf("search results count = %d, want 1", len(searchResp.Results))
+ }
+ if !searchResp.Results[0].Installed || searchResp.Results[0].InstalledName != "github" {
+ t.Fatalf("search result should be treated as installed after registry install, got %#v", searchResp.Results[0])
+ }
+}
+
+func TestHandleInstallSkillForcePreservesExistingSkillOnFailure(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ skillDir := filepath.Join(workspace, "skills", "github")
+ if err := os.MkdirAll(skillDir, 0o755); err != nil {
+ t.Fatalf("MkdirAll() error = %v", err)
+ }
+ oldContent := []byte("---\nname: github\ndescription: Existing skill\n---\n# Existing\n")
+ if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), oldContent, 0o644); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/skills/github":
+ json.NewEncoder(w).Encode(map[string]any{
+ "slug": "github",
+ "displayName": "GitHub",
+ "summary": "GitHub registry skill",
+ "latestVersion": map[string]any{
+ "version": "1.2.3",
+ },
+ "moderation": map[string]any{
+ "isMalwareBlocked": false,
+ "isSuspicious": false,
+ },
+ })
+ case "/api/v1/download":
+ http.Error(w, "upstream download failed", http.StatusBadGateway)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ body, err := json.Marshal(installSkillRequest{
+ Slug: "github",
+ Registry: "clawhub",
+ Force: true,
+ })
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadGateway {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadGateway, rec.Body.String())
+ }
+
+ gotContent, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md"))
+ if err != nil {
+ t.Fatalf("ReadFile() error = %v", err)
+ }
+ if !bytes.Equal(gotContent, oldContent) {
+ t.Fatalf("existing skill should remain unchanged, got:\n%s", string(gotContent))
+ }
+}
+
+func TestHandleInstallSkillDefaultsRegistryToGitHub(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v3/repos/foo/bar":
+ json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"})
+ case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
+ assert.Equal(t, "ref=master", r.URL.RawQuery)
+ json.NewEncoder(w).Encode([]map[string]any{
+ {
+ "type": "file",
+ "name": "SKILL.md",
+ "download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
+ },
+ })
+ case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
+ _, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n"))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
+ if !ok {
+ t.Fatalf("github registry missing from default config")
+ }
+ githubRegistry.BaseURL = server.URL
+ cfg.Tools.Skills.Registries.Set("github", githubRegistry)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ body, err := json.Marshal(installSkillRequest{
+ Slug: "foo/bar/.agents/skills/pr-review",
+ })
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp installSkillResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if resp.Registry != "github" {
+ t.Fatalf("resp.Registry = %q, want github", resp.Registry)
+ }
+}
+
+func TestHandleInstallSkillTracksGitHubURLInstallsAsInstalled(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v3/repos/foo/bar":
+ json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"})
+ case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
+ assert.Equal(t, "ref=master", r.URL.RawQuery)
+ json.NewEncoder(w).Encode([]map[string]any{{
+ "type": "file",
+ "name": "SKILL.md",
+ "download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
+ }})
+ case "/api/v3/search/code":
+ json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{{
+ "path": ".agents/skills/pr-review/SKILL.md",
+ "score": 10,
+ "repository": map[string]any{
+ "full_name": "foo/bar",
+ "name": "bar",
+ "description": "PR review skill",
+ "default_branch": "master",
+ },
+ }},
+ })
+ case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
+ _, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n"))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ setGithubBaseURL(cfg, server.URL)
+ clawHubRegistry, _ := cfg.Tools.Skills.Registries.Get("clawhub")
+ clawHubRegistry.Enabled = false
+ cfg.Tools.Skills.Registries.Set("clawhub", clawHubRegistry)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ installBody, err := json.Marshal(installSkillRequest{
+ Slug: server.URL + "/foo/bar/tree/master/.agents/skills/pr-review",
+ })
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+
+ installRec := httptest.NewRecorder()
+ installReq := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(installBody))
+ installReq.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(installRec, installReq)
+
+ if installRec.Code != http.StatusOK {
+ t.Fatalf("install status = %d, want %d, body=%s", installRec.Code, http.StatusOK, installRec.Body.String())
+ }
+
+ searchRec := httptest.NewRecorder()
+ searchReq := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil)
+ mux.ServeHTTP(searchRec, searchReq)
+
+ if searchRec.Code != http.StatusOK {
+ t.Fatalf("search status = %d, want %d, body=%s", searchRec.Code, http.StatusOK, searchRec.Body.String())
+ }
+
+ var searchResp skillSearchResponse
+ if err := json.Unmarshal(searchRec.Body.Bytes(), &searchResp); err != nil {
+ t.Fatalf("Unmarshal(search response) error = %v", err)
+ }
+ if len(searchResp.Results) != 1 {
+ t.Fatalf("search results count = %d, want 1", len(searchResp.Results))
+ }
+ if !searchResp.Results[0].Installed || searchResp.Results[0].InstalledName != "pr-review" {
+ t.Fatalf("search result should be treated as installed after URL install, got %#v", searchResp.Results[0])
+ }
+}
+
+func TestHandleSearchSkillsMarksDirectoryCollisionAsInstalled(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ skillDir := filepath.Join(workspace, "skills", "pr-review")
+ if err := os.MkdirAll(skillDir, 0o755); err != nil {
+ t.Fatalf("MkdirAll() error = %v", err)
+ }
+ if err := os.WriteFile(
+ filepath.Join(skillDir, "SKILL.md"),
+ []byte("---\nname: pr-review\ndescription: Workspace PR review skill\n---\n# PR Review\n"),
+ 0o644,
+ ); err != nil {
+ t.Fatalf("WriteFile(SKILL.md) error = %v", err)
+ }
+ if err := writeSkillOriginMeta(skillDir, installedSkillOriginMeta{
+ Version: 1,
+ OriginKind: "third_party",
+ Registry: "github",
+ Slug: "foo/bar/.agents/skills/pr-review",
+ RegistryURL: "https://github.com/foo/bar/tree/master/.agents/skills/pr-review",
+ InstalledVersion: "master",
+ InstalledAt: time.Now().UnixMilli(),
+ }); err != nil {
+ t.Fatalf("writeSkillOriginMeta() error = %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/search":
+ json.NewEncoder(w).Encode(map[string]any{
+ "results": []map[string]any{{
+ "slug": "pr-review",
+ "displayName": "PR Review",
+ "summary": "ClawHub PR review skill",
+ "version": "1.2.3",
+ }},
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ githubRegistry, _ := cfg.Tools.Skills.Registries.Get("github")
+ githubRegistry.Enabled = false
+ cfg.Tools.Skills.Registries.Set("github", githubRegistry)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp skillSearchResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Results) != 1 {
+ t.Fatalf("results count = %d, want 1", len(resp.Results))
+ }
+ if !resp.Results[0].Installed || resp.Results[0].InstalledName != "pr-review" {
+ t.Fatalf("search result should be treated as installed when directory is occupied, got %#v", resp.Results[0])
+ }
+}
+
+func TestHandleInstallSkillRollsBackOnOriginMetadataWriteFailure(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ zipContent := buildSkillZip(t, map[string]string{
+ "SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n",
+ })
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/skills/github":
+ json.NewEncoder(w).Encode(map[string]any{
+ "slug": "github",
+ "displayName": "GitHub",
+ "summary": "GitHub registry skill",
+ "latestVersion": map[string]any{
+ "version": "1.2.3",
+ },
+ "moderation": map[string]any{
+ "isMalwareBlocked": false,
+ "isSuspicious": false,
+ },
+ })
+ case "/api/v1/download":
+ w.Header().Set("Content-Type", "application/zip")
+ _, _ = w.Write(zipContent)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ previousPersist := persistSkillOriginMeta
+ persistSkillOriginMeta = func(targetDir string, meta installedSkillOriginMeta) error {
+ return errors.New("forced metadata failure")
+ }
+ defer func() {
+ persistSkillOriginMeta = previousPersist
+ }()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ body, err := json.Marshal(installSkillRequest{
+ Slug: "github",
+ Registry: "clawhub",
+ })
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusInternalServerError, rec.Body.String())
+ }
+
+ skillDir := filepath.Join(workspace, "skills", "github")
+ if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
+ t.Fatalf("skill directory should be removed after metadata write failure, stat err=%v", err)
+ }
+}
+
+func TestHandleInstallSkillSerializesConcurrentRequests(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ zipContent := buildSkillZip(t, map[string]string{
+ "SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n",
+ })
+
+ downloadStarted := make(chan struct{}, 2)
+ releaseFirstDownload := make(chan struct{})
+ downloadCount := 0
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/skills/github":
+ json.NewEncoder(w).Encode(map[string]any{
+ "slug": "github",
+ "displayName": "GitHub",
+ "summary": "GitHub registry skill",
+ "latestVersion": map[string]any{
+ "version": "1.2.3",
+ },
+ "moderation": map[string]any{
+ "isMalwareBlocked": false,
+ "isSuspicious": false,
+ },
+ })
+ case "/api/v1/download":
+ downloadCount++
+ downloadStarted <- struct{}{}
+ if downloadCount == 1 {
+ <-releaseFirstDownload
+ }
+ w.Header().Set("Content-Type", "application/zip")
+ _, _ = w.Write(zipContent)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ body, err := json.Marshal(installSkillRequest{
+ Slug: "github",
+ Registry: "clawhub",
+ })
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+
+ type installResult struct {
+ code int
+ body string
+ }
+ results := make(chan installResult, 2)
+ startInstall := func() {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+ results <- installResult{
+ code: rec.Code,
+ body: rec.Body.String(),
+ }
+ }
+
+ go startInstall()
+
+ select {
+ case <-downloadStarted:
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for first install download to start")
+ }
+
+ go startInstall()
+
+ select {
+ case <-downloadStarted:
+ t.Fatal("second install should not reach registry download before the first request completes")
+ case <-time.After(200 * time.Millisecond):
+ }
+
+ close(releaseFirstDownload)
+
+ firstResult := <-results
+ secondResult := <-results
+
+ codes := map[int]int{
+ firstResult.code: 1,
+ secondResult.code: 1,
+ }
+ if codes[http.StatusOK] != 1 || codes[http.StatusConflict] != 1 {
+ t.Fatalf(
+ "unexpected install results: first=(%d, %q) second=(%d, %q)",
+ firstResult.code,
+ firstResult.body,
+ secondResult.code,
+ secondResult.body,
+ )
+ }
+}
+
+func TestHandleImportSkillWaitsForConcurrentInstall(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ zipContent := buildSkillZip(t, map[string]string{
+ "SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n",
+ })
+
+ downloadStarted := make(chan struct{}, 1)
+ releaseDownload := make(chan struct{})
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/skills/github":
+ json.NewEncoder(w).Encode(map[string]any{
+ "slug": "github",
+ "displayName": "GitHub",
+ "summary": "GitHub registry skill",
+ "latestVersion": map[string]any{
+ "version": "1.2.3",
+ },
+ "moderation": map[string]any{
+ "isMalwareBlocked": false,
+ "isSuspicious": false,
+ },
+ })
+ case "/api/v1/download":
+ downloadStarted <- struct{}{}
+ <-releaseDownload
+ w.Header().Set("Content-Type", "application/zip")
+ _, _ = w.Write(zipContent)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ installBody, err := json.Marshal(installSkillRequest{
+ Slug: "github",
+ Registry: "clawhub",
+ })
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+
+ type result struct {
+ code int
+ body string
+ }
+ installResults := make(chan result, 1)
+ importResults := make(chan result, 1)
+
+ go func() {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(installBody))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+ installResults <- result{code: rec.Code, body: rec.Body.String()}
+ }()
+
+ select {
+ case <-downloadStarted:
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for install download to start")
+ }
+
+ var importBody bytes.Buffer
+ writer := multipart.NewWriter(&importBody)
+ part, err := writer.CreateFormFile("file", "github.md")
+ if err != nil {
+ t.Fatalf("CreateFormFile() error = %v", err)
+ }
+ if _, err := io.WriteString(part, "# GitHub\n"); err != nil {
+ t.Fatalf("WriteString() error = %v", err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatalf("Close() error = %v", err)
+ }
+
+ go func() {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &importBody)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ mux.ServeHTTP(rec, req)
+ importResults <- result{code: rec.Code, body: rec.Body.String()}
+ }()
+
+ select {
+ case got := <-importResults:
+ t.Fatalf("import should wait for the install lock, got early response (%d, %q)", got.code, got.body)
+ case <-time.After(200 * time.Millisecond):
+ }
+
+ close(releaseDownload)
+
+ installResult := <-installResults
+ importResult := <-importResults
+
+ if installResult.code != http.StatusOK {
+ t.Fatalf("install status = %d, want %d, body=%s", installResult.code, http.StatusOK, installResult.body)
+ }
+ if importResult.code != http.StatusConflict {
+ t.Fatalf("import status = %d, want %d, body=%s", importResult.code, http.StatusConflict, importResult.body)
+ }
+}
+
+func TestHandleInstallSkillRejectsInvalidArchive(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, loadErr := config.LoadConfig(configPath)
+ if loadErr != nil {
+ t.Fatalf("LoadConfig() error = %v", loadErr)
+ }
+ workspace := filepath.Join(t.TempDir(), "workspace")
+ cfg.Agents.Defaults.Workspace = workspace
+
+ zipContent := buildSkillZip(t, map[string]string{
+ "README.md": "# Not a skill\n",
+ })
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/skills/github":
+ json.NewEncoder(w).Encode(map[string]any{
+ "slug": "github",
+ "displayName": "GitHub",
+ "summary": "GitHub registry skill",
+ "latestVersion": map[string]any{
+ "version": "1.2.3",
+ },
+ "moderation": map[string]any{
+ "isMalwareBlocked": false,
+ "isSuspicious": false,
+ },
+ })
+ case "/api/v1/download":
+ w.Header().Set("Content-Type", "application/zip")
+ _, _ = w.Write(zipContent)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ setClawHubBaseURL(cfg, server.URL)
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ body, err := json.Marshal(installSkillRequest{
+ Slug: "github",
+ Registry: "clawhub",
+ })
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadGateway {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadGateway, rec.Body.String())
+ }
+
+ skillDir := filepath.Join(workspace, "skills", "github")
+ if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
+ t.Fatalf("invalid installed archive should be removed, stat err=%v", err)
+ }
+}
+
+func buildSkillZip(t *testing.T, files map[string]string) []byte {
+ t.Helper()
+
+ var buf bytes.Buffer
+ zipWriter := zip.NewWriter(&buf)
+ for name, content := range files {
+ writer, err := zipWriter.Create(name)
+ if err != nil {
+ t.Fatalf("Create(%q) error = %v", name, err)
+ }
+ if _, err := io.WriteString(writer, content); err != nil {
+ t.Fatalf("WriteString(%q) error = %v", name, err)
+ }
+ }
+ if err := zipWriter.Close(); err != nil {
+ t.Fatalf("Close() error = %v", err)
+ }
+ return buf.Bytes()
+}
diff --git a/web/backend/api/startup.go b/web/backend/api/startup.go
index 1c685bc90..8a3b8e8ff 100644
--- a/web/backend/api/startup.go
+++ b/web/backend/api/startup.go
@@ -90,6 +90,9 @@ func (h *Handler) resolveLaunchCommand() (string, []string, error) {
}
args := []string{"-no-browser"}
+ if h.debug {
+ args = append(args, "-d")
+ }
if h.configPath != "" {
args = append(args, h.configPath)
}
diff --git a/web/backend/api/startup_test.go b/web/backend/api/startup_test.go
index cfa9b4c53..c224d36e2 100644
--- a/web/backend/api/startup_test.go
+++ b/web/backend/api/startup_test.go
@@ -45,6 +45,29 @@ func TestResolveLaunchCommandUsesConfigFileDefaults(t *testing.T) {
}
}
+func TestResolveLaunchCommandIncludesDebugFlagWhenEnabled(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ h.SetDebug(true)
+
+ _, args, err := h.resolveLaunchCommand()
+ if err != nil {
+ t.Fatalf("resolveLaunchCommand() error = %v", err)
+ }
+ if len(args) != 3 {
+ t.Fatalf("args len = %d, want 3 (got %v)", len(args), args)
+ }
+ if args[0] != "-no-browser" {
+ t.Fatalf("args[0] = %q, want %q", args[0], "-no-browser")
+ }
+ if args[1] != "-d" {
+ t.Fatalf("args[1] = %q, want %q", args[1], "-d")
+ }
+ if args[2] != configPath {
+ t.Fatalf("args[2] = %q, want %q", args[2], configPath)
+ }
+}
+
func TestBuildDarwinPlistIncludesRunAtLoad(t *testing.T) {
plist := buildDarwinPlist("/tmp/picoclaw-web", []string{"-no-browser", "/tmp/config.json"})
if !strings.Contains(plist, "RunAtLoad ") {
diff --git a/web/backend/api/tools.go b/web/backend/api/tools.go
index 9df4a7091..3476e3c53 100644
--- a/web/backend/api/tools.go
+++ b/web/backend/api/tools.go
@@ -5,8 +5,10 @@ import (
"fmt"
"net/http"
"runtime"
+ "strings"
"github.com/sipeed/picoclaw/pkg/config"
+ picotools "github.com/sipeed/picoclaw/pkg/tools"
)
type toolCatalogEntry struct {
@@ -33,6 +35,39 @@ type toolStateRequest struct {
Enabled bool `json:"enabled"`
}
+type webSearchProviderOption struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ Configured bool `json:"configured"`
+ Current bool `json:"current"`
+ RequiresAuth bool `json:"requires_auth"`
+}
+
+type webSearchProviderConfig struct {
+ Enabled bool `json:"enabled"`
+ MaxResults int `json:"max_results"`
+ BaseURL string `json:"base_url,omitempty"`
+ APIKey string `json:"api_key,omitempty"`
+ APIKeys []string `json:"api_keys,omitempty"`
+ APIKeySet bool `json:"api_key_set,omitempty"`
+}
+
+type webSearchConfigResponse struct {
+ Provider string `json:"provider"`
+ CurrentService string `json:"current_service"`
+ PreferNative bool `json:"prefer_native"`
+ Proxy string `json:"proxy,omitempty"`
+ Providers []webSearchProviderOption `json:"providers"`
+ Settings map[string]webSearchProviderConfig `json:"settings"`
+}
+
+type webSearchConfigRequest struct {
+ Provider string `json:"provider"`
+ PreferNative bool `json:"prefer_native"`
+ Proxy string `json:"proxy"`
+ Settings map[string]webSearchProviderConfig `json:"settings"`
+}
+
var toolCatalog = []toolCatalogEntry{
{
Name: "read_file",
@@ -136,6 +171,12 @@ var toolCatalog = []toolCatalogEntry{
Category: "hardware",
ConfigKey: "spi",
},
+ {
+ Name: "serial",
+ Description: "Interact with serial ports exposed on the host.",
+ Category: "hardware",
+ ConfigKey: "serial",
+ },
{
Name: "tool_search_tool_regex",
Description: "Discover hidden MCP tools by regex search when tool discovery is enabled.",
@@ -153,6 +194,8 @@ var toolCatalog = []toolCatalogEntry{
func (h *Handler) registerToolRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/tools", h.handleListTools)
mux.HandleFunc("PUT /api/tools/{name}/state", h.handleUpdateToolState)
+ mux.HandleFunc("GET /api/tools/web-search-config", h.handleGetWebSearchConfig)
+ mux.HandleFunc("PUT /api/tools/web-search-config", h.handleUpdateWebSearchConfig)
}
func (h *Handler) handleListTools(w http.ResponseWriter, r *http.Request) {
@@ -224,8 +267,12 @@ func buildToolSupport(cfg *config.Config) []toolSupportItem {
status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseRegex)
case "tool_search_tool_bm25":
status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseBM25)
+ case "web_search":
+ status, reasonCode = resolveWebSearchToolSupport(cfg)
case "i2c", "spi":
status, reasonCode = resolveHardwareToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey))
+ case "serial":
+ status, reasonCode = resolveSerialToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey))
default:
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
status = "enabled"
@@ -254,6 +301,18 @@ func resolveHardwareToolSupport(enabled bool) (string, string) {
return "enabled", ""
}
+func resolveSerialToolSupport(enabled bool) (string, string) {
+ if !enabled {
+ return "disabled", ""
+ }
+ switch runtime.GOOS {
+ case "linux", "darwin", "windows":
+ return "enabled", ""
+ default:
+ return "blocked", "requires_serial_platform"
+ }
+}
+
func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string, string) {
if !cfg.Tools.IsToolEnabled("mcp") {
return "disabled", ""
@@ -267,6 +326,13 @@ func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string
return "enabled", ""
}
+func resolveWebSearchToolSupport(cfg *config.Config) (string, string) {
+ if !cfg.Tools.IsToolEnabled("web") {
+ return "disabled", ""
+ }
+ return "enabled", ""
+}
+
func applyToolState(cfg *config.Config, toolName string, enabled bool) error {
switch toolName {
case "read_file":
@@ -316,6 +382,8 @@ func applyToolState(cfg *config.Config, toolName string, enabled bool) error {
cfg.Tools.I2C.Enabled = enabled
case "spi":
cfg.Tools.SPI.Enabled = enabled
+ case "serial":
+ cfg.Tools.Serial.Enabled = enabled
case "tool_search_tool_regex":
cfg.Tools.MCP.Discovery.UseRegex = enabled
if enabled {
@@ -333,3 +401,274 @@ func applyToolState(cfg *config.Config, toolName string, enabled bool) error {
}
return nil
}
+
+func (h *Handler) handleGetWebSearchConfig(w http.ResponseWriter, r *http.Request) {
+ cfg, err := config.LoadConfig(h.configPath)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(buildWebSearchConfigResponse(cfg)); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ }
+}
+
+func (h *Handler) handleUpdateWebSearchConfig(w http.ResponseWriter, r *http.Request) {
+ cfg, err := config.LoadConfig(h.configPath)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ var req webSearchConfigRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ provider := normalizeWebSearchProvider(req.Provider)
+ if provider == "" {
+ http.Error(w, "invalid web search provider", http.StatusBadRequest)
+ return
+ }
+
+ cfg.Tools.Web.Provider = provider
+ cfg.Tools.Web.PreferNative = req.PreferNative
+ cfg.Tools.Web.Proxy = strings.TrimSpace(req.Proxy)
+
+ if settings, ok := req.Settings["sogou"]; ok {
+ cfg.Tools.Web.Sogou.Enabled = settings.Enabled
+ cfg.Tools.Web.Sogou.MaxResults = settings.MaxResults
+ }
+ if settings, ok := req.Settings["duckduckgo"]; ok {
+ cfg.Tools.Web.DuckDuckGo.Enabled = settings.Enabled
+ cfg.Tools.Web.DuckDuckGo.MaxResults = settings.MaxResults
+ }
+ if settings, ok := req.Settings["brave"]; ok {
+ cfg.Tools.Web.Brave.Enabled = settings.Enabled
+ cfg.Tools.Web.Brave.MaxResults = settings.MaxResults
+ if keys, ok := normalizeWebSearchAPIKeys(settings.APIKeys, settings.APIKey); ok {
+ cfg.Tools.Web.Brave.SetAPIKeys(keys)
+ }
+ }
+ if settings, ok := req.Settings["tavily"]; ok {
+ cfg.Tools.Web.Tavily.Enabled = settings.Enabled
+ cfg.Tools.Web.Tavily.MaxResults = settings.MaxResults
+ cfg.Tools.Web.Tavily.BaseURL = strings.TrimSpace(settings.BaseURL)
+ if keys, ok := normalizeWebSearchAPIKeys(settings.APIKeys, settings.APIKey); ok {
+ cfg.Tools.Web.Tavily.SetAPIKeys(keys)
+ }
+ }
+ if settings, ok := req.Settings["perplexity"]; ok {
+ cfg.Tools.Web.Perplexity.Enabled = settings.Enabled
+ cfg.Tools.Web.Perplexity.MaxResults = settings.MaxResults
+ if keys, ok := normalizeWebSearchAPIKeys(settings.APIKeys, settings.APIKey); ok {
+ cfg.Tools.Web.Perplexity.APIKeys = config.SimpleSecureStrings(keys...)
+ }
+ }
+ if settings, ok := req.Settings["searxng"]; ok {
+ cfg.Tools.Web.SearXNG.Enabled = settings.Enabled
+ cfg.Tools.Web.SearXNG.MaxResults = settings.MaxResults
+ cfg.Tools.Web.SearXNG.BaseURL = strings.TrimSpace(settings.BaseURL)
+ }
+ if settings, ok := req.Settings["glm_search"]; ok {
+ cfg.Tools.Web.GLMSearch.Enabled = settings.Enabled
+ cfg.Tools.Web.GLMSearch.MaxResults = settings.MaxResults
+ cfg.Tools.Web.GLMSearch.BaseURL = strings.TrimSpace(settings.BaseURL)
+ if key := strings.TrimSpace(settings.APIKey); key != "" {
+ cfg.Tools.Web.GLMSearch.APIKey = *config.NewSecureString(key)
+ }
+ }
+ if settings, ok := req.Settings["baidu_search"]; ok {
+ cfg.Tools.Web.BaiduSearch.Enabled = settings.Enabled
+ cfg.Tools.Web.BaiduSearch.MaxResults = settings.MaxResults
+ cfg.Tools.Web.BaiduSearch.BaseURL = strings.TrimSpace(settings.BaseURL)
+ if key := strings.TrimSpace(settings.APIKey); key != "" {
+ cfg.Tools.Web.BaiduSearch.APIKey = *config.NewSecureString(key)
+ }
+ }
+
+ if err := config.SaveConfig(h.configPath, cfg); err != nil {
+ http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(buildWebSearchConfigResponse(cfg)); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ }
+}
+
+func normalizeWebSearchProvider(provider string) string {
+ switch strings.ToLower(strings.TrimSpace(provider)) {
+ case "", "auto":
+ return "auto"
+ case "sogou", "brave", "tavily", "duckduckgo", "perplexity", "searxng", "glm_search", "baidu_search":
+ return strings.ToLower(strings.TrimSpace(provider))
+ default:
+ return ""
+ }
+}
+
+func normalizeWebSearchAPIKeys(apiKeys []string, apiKey string) ([]string, bool) {
+ if apiKeys != nil {
+ keys := make([]string, 0, len(apiKeys))
+ seen := make(map[string]struct{}, len(apiKeys))
+ for _, key := range apiKeys {
+ trimmed := strings.TrimSpace(key)
+ if trimmed == "" {
+ continue
+ }
+ if _, ok := seen[trimmed]; ok {
+ continue
+ }
+ seen[trimmed] = struct{}{}
+ keys = append(keys, trimmed)
+ }
+ return keys, true
+ }
+
+ if trimmed := strings.TrimSpace(apiKey); trimmed != "" {
+ return []string{trimmed}, true
+ }
+
+ return nil, false
+}
+
+func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse {
+ opts := picotools.WebSearchToolOptionsFromConfig(cfg)
+ current := resolveCurrentWebSearchProvider(cfg)
+ settings := map[string]webSearchProviderConfig{
+ "sogou": {
+ Enabled: cfg.Tools.Web.Sogou.Enabled,
+ MaxResults: cfg.Tools.Web.Sogou.MaxResults,
+ },
+ "duckduckgo": {
+ Enabled: cfg.Tools.Web.DuckDuckGo.Enabled,
+ MaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
+ },
+ "brave": {
+ Enabled: cfg.Tools.Web.Brave.Enabled,
+ MaxResults: cfg.Tools.Web.Brave.MaxResults,
+ APIKeySet: len(cfg.Tools.Web.Brave.APIKeys.Values()) > 0,
+ },
+ "tavily": {
+ Enabled: cfg.Tools.Web.Tavily.Enabled,
+ MaxResults: cfg.Tools.Web.Tavily.MaxResults,
+ BaseURL: cfg.Tools.Web.Tavily.BaseURL,
+ APIKeySet: len(cfg.Tools.Web.Tavily.APIKeys.Values()) > 0,
+ },
+ "perplexity": {
+ Enabled: cfg.Tools.Web.Perplexity.Enabled,
+ MaxResults: cfg.Tools.Web.Perplexity.MaxResults,
+ APIKeySet: len(cfg.Tools.Web.Perplexity.APIKeys.Values()) > 0,
+ },
+ "searxng": {
+ Enabled: cfg.Tools.Web.SearXNG.Enabled,
+ MaxResults: cfg.Tools.Web.SearXNG.MaxResults,
+ BaseURL: cfg.Tools.Web.SearXNG.BaseURL,
+ },
+ "glm_search": {
+ Enabled: cfg.Tools.Web.GLMSearch.Enabled,
+ MaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
+ BaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
+ APIKeySet: cfg.Tools.Web.GLMSearch.APIKey.String() != "",
+ },
+ "baidu_search": {
+ Enabled: cfg.Tools.Web.BaiduSearch.Enabled,
+ MaxResults: cfg.Tools.Web.BaiduSearch.MaxResults,
+ BaseURL: cfg.Tools.Web.BaiduSearch.BaseURL,
+ APIKeySet: cfg.Tools.Web.BaiduSearch.APIKey.String() != "",
+ },
+ }
+
+ providers := []webSearchProviderOption{
+ {
+ ID: "auto",
+ Label: "Auto",
+ Configured: current != "",
+ Current: cfg.Tools.Web.Provider == "" ||
+ cfg.Tools.Web.Provider == "auto",
+ },
+ {
+ ID: "sogou",
+ Label: "Sogou",
+ Configured: picotools.WebSearchProviderReady(opts, "sogou"),
+ Current: current == "sogou",
+ },
+ {
+ ID: "duckduckgo",
+ Label: "DuckDuckGo",
+ Configured: picotools.WebSearchProviderReady(opts, "duckduckgo"),
+ Current: current == "duckduckgo",
+ },
+ {
+ ID: "brave",
+ Label: "Brave Search",
+ Configured: picotools.WebSearchProviderReady(opts, "brave"),
+ Current: current == "brave",
+ RequiresAuth: true,
+ },
+ {
+ ID: "tavily",
+ Label: "Tavily",
+ Configured: picotools.WebSearchProviderReady(opts, "tavily"),
+ Current: current == "tavily",
+ RequiresAuth: true,
+ },
+ {
+ ID: "perplexity",
+ Label: "Perplexity",
+ Configured: picotools.WebSearchProviderReady(opts, "perplexity"),
+ Current: current == "perplexity",
+ RequiresAuth: true,
+ },
+ {
+ ID: "searxng",
+ Label: "SearXNG",
+ Configured: picotools.WebSearchProviderReady(opts, "searxng"),
+ Current: current == "searxng",
+ },
+ {
+ ID: "glm_search",
+ Label: "GLM Search",
+ Configured: picotools.WebSearchProviderReady(opts, "glm_search"),
+ Current: current == "glm_search",
+ RequiresAuth: true,
+ },
+ {
+ ID: "baidu_search",
+ Label: "Baidu Search",
+ Configured: picotools.WebSearchProviderReady(opts, "baidu_search"),
+ Current: current == "baidu_search",
+ RequiresAuth: true,
+ },
+ }
+
+ provider := cfg.Tools.Web.Provider
+ if provider == "" {
+ provider = "auto"
+ }
+
+ return webSearchConfigResponse{
+ Provider: provider,
+ CurrentService: current,
+ PreferNative: cfg.Tools.Web.PreferNative,
+ Proxy: cfg.Tools.Web.Proxy,
+ Providers: providers,
+ Settings: settings,
+ }
+}
+
+func resolveCurrentWebSearchProvider(cfg *config.Config) string {
+ if cfg == nil || !cfg.Tools.IsToolEnabled("web") {
+ return ""
+ }
+ selected, err := picotools.ResolveWebSearchProviderName(picotools.WebSearchToolOptionsFromConfig(cfg), "")
+ if err != nil {
+ return ""
+ }
+ return selected
+}
diff --git a/web/backend/api/tools_test.go b/web/backend/api/tools_test.go
index 646cefbe2..a09a49fd6 100644
--- a/web/backend/api/tools_test.go
+++ b/web/backend/api/tools_test.go
@@ -92,9 +92,36 @@ func TestHandleListTools(t *testing.T) {
if gotTools["i2c"].Status != "disabled" {
t.Fatalf("i2c status = %q, want disabled on linux when config is off", gotTools["i2c"].Status)
}
+ if gotTools["serial"].Status != "disabled" {
+ t.Fatalf("serial status = %q, want disabled when config is off", gotTools["serial"].Status)
+ }
+
+ cfg.Tools.Serial.Enabled = true
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, "/api/tools", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ gotTools = make(map[string]toolSupportItem, len(resp.Tools))
+ for _, tool := range resp.Tools {
+ gotTools[tool.Name] = tool
+ }
+ if gotTools["serial"].Status != "enabled" {
+ t.Fatalf("serial = %#v, want enabled on linux when config is on", gotTools["serial"])
+ }
} else {
cfg.Tools.I2C.Enabled = true
cfg.Tools.SPI.Enabled = true
+ cfg.Tools.Serial.Enabled = true
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
@@ -120,6 +147,16 @@ func TestHandleListTools(t *testing.T) {
if gotTools["spi"].Status != "blocked" || gotTools["spi"].ReasonCode != "requires_linux" {
t.Fatalf("spi = %#v, want blocked/requires_linux", gotTools["spi"])
}
+ switch runtime.GOOS {
+ case "darwin", "windows":
+ if gotTools["serial"].Status != "enabled" {
+ t.Fatalf("serial = %#v, want enabled on supported host", gotTools["serial"])
+ }
+ default:
+ if gotTools["serial"].Status != "blocked" || gotTools["serial"].ReasonCode != "requires_serial_platform" {
+ t.Fatalf("serial = %#v, want blocked/requires_serial_platform", gotTools["serial"])
+ }
+ }
}
}
@@ -195,4 +232,373 @@ func TestHandleUpdateToolState(t *testing.T) {
if !updated.Tools.Cron.Enabled {
t.Fatalf("cron should be enabled: %#v", updated.Tools.Cron)
}
+
+ rec4 := httptest.NewRecorder()
+ req4 := httptest.NewRequest(
+ http.MethodPut,
+ "/api/tools/serial/state",
+ bytes.NewBufferString(`{"enabled":true}`),
+ )
+ req4.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec4, req4)
+ if rec4.Code != http.StatusOK {
+ t.Fatalf("serial status = %d, want %d, body=%s", rec4.Code, http.StatusOK, rec4.Body.String())
+ }
+
+ updated, err = config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig(updated serial) error = %v", err)
+ }
+ if !updated.Tools.Serial.Enabled {
+ t.Fatalf("serial should be enabled: %#v", updated.Tools.Serial)
+ }
+}
+
+func TestHandleListTools_ReportsWebSearchEnabledWhenToolIsOn(t *testing.T) {
+ tests := []struct {
+ name string
+ preferNative bool
+ }{
+ {name: "without prefer_native", preferNative: false},
+ {name: "with prefer_native", preferNative: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.Tools.Web.PreferNative = tt.preferNative
+ cfg.Tools.Web.Provider = "brave"
+ cfg.Tools.Web.Sogou.Enabled = false
+ cfg.Tools.Web.DuckDuckGo.Enabled = false
+ cfg.Tools.Web.Brave.Enabled = true
+ cfg.Tools.Web.Brave.SetAPIKeys(nil)
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/tools", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp toolSupportResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ for _, tool := range resp.Tools {
+ if tool.Name != "web_search" {
+ continue
+ }
+ if tool.Status != "enabled" || tool.ReasonCode != "" {
+ t.Fatalf("web_search = %#v, want enabled with no reason code", tool)
+ }
+ return
+ }
+
+ t.Fatal("expected web_search in response")
+ })
+ }
+}
+
+func TestHandleGetWebSearchConfig(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.Tools.Web.PreferNative = false
+ cfg.Tools.Web.Provider = "sogou"
+ cfg.Tools.Web.Sogou.Enabled = true
+ cfg.Tools.Web.Sogou.MaxResults = 6
+ cfg.Tools.Web.Brave.Enabled = true
+ cfg.Tools.Web.Brave.SetAPIKey("brave-test-key")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/tools/web-search-config", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp webSearchConfigResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if resp.Provider != "sogou" {
+ t.Fatalf("provider = %q, want sogou", resp.Provider)
+ }
+ if resp.CurrentService != "sogou" {
+ t.Fatalf("current_service = %q, want sogou", resp.CurrentService)
+ }
+ if !resp.Settings["brave"].APIKeySet {
+ t.Fatalf("brave api_key_set should be true: %#v", resp.Settings["brave"])
+ }
+}
+
+func TestHandleGetWebSearchConfig_DoesNotExposeNativeAsCurrentService(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.Tools.Web.PreferNative = true
+ cfg.Tools.Web.Provider = "brave"
+ cfg.Tools.Web.Sogou.Enabled = false
+ cfg.Tools.Web.DuckDuckGo.Enabled = false
+ cfg.Tools.Web.Brave.Enabled = true
+ cfg.Tools.Web.Brave.SetAPIKeys(nil)
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/tools/web-search-config", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp webSearchConfigResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if !resp.PreferNative {
+ t.Fatal("prefer_native should remain true in response")
+ }
+ if resp.CurrentService != "" {
+ t.Fatalf("current_service = %q, want empty when no external provider is ready", resp.CurrentService)
+ }
+}
+
+func TestHandleUpdateWebSearchConfig(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.Tools.Web.Brave.SetAPIKeys([]string{"brave-old-1", "brave-old-2"})
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(
+ http.MethodPut,
+ "/api/tools/web-search-config",
+ bytes.NewBufferString(`{
+ "provider":"brave",
+ "prefer_native":false,
+ "proxy":"http://127.0.0.1:7890",
+ "settings":{
+ "sogou":{"enabled":true,"max_results":4},
+ "brave":{"enabled":true,"max_results":7,"api_key":"brave-new-key"},
+ "duckduckgo":{"enabled":false,"max_results":3}
+ }
+ }`),
+ )
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if updated.Tools.Web.Provider != "brave" {
+ t.Fatalf("provider = %q, want brave", updated.Tools.Web.Provider)
+ }
+ if updated.Tools.Web.PreferNative {
+ t.Fatal("prefer_native should be false after update")
+ }
+ if updated.Tools.Web.Proxy != "http://127.0.0.1:7890" {
+ t.Fatalf("proxy = %q", updated.Tools.Web.Proxy)
+ }
+ if !updated.Tools.Web.Sogou.Enabled || updated.Tools.Web.Sogou.MaxResults != 4 {
+ t.Fatalf("sogou config not updated: %#v", updated.Tools.Web.Sogou)
+ }
+ if !updated.Tools.Web.Brave.Enabled || updated.Tools.Web.Brave.MaxResults != 7 {
+ t.Fatalf("brave config not updated: %#v", updated.Tools.Web.Brave)
+ }
+ if updated.Tools.Web.Brave.APIKey() != "brave-new-key" {
+ t.Fatalf("brave api key not updated")
+ }
+}
+
+func TestHandleUpdateWebSearchConfig_PreservesAndReplacesMultiKeys(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.Tools.Web.Brave.SetAPIKeys([]string{"brave-old-1", "brave-old-2"})
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ t.Fatalf("SaveConfig() error = %v", saveErr)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(
+ http.MethodPut,
+ "/api/tools/web-search-config",
+ bytes.NewBufferString(`{
+ "provider":"auto",
+ "prefer_native":true,
+ "proxy":"",
+ "settings":{
+ "brave":{"enabled":true,"max_results":7}
+ }
+ }`),
+ )
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.Tools.Web.Brave.APIKeys.Values(); len(got) != 2 ||
+ got[0] != "brave-old-1" || got[1] != "brave-old-2" {
+ t.Fatalf("brave api keys should be preserved, got %#v", got)
+ }
+
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(
+ http.MethodPut,
+ "/api/tools/web-search-config",
+ bytes.NewBufferString(`{
+ "provider":"auto",
+ "prefer_native":true,
+ "proxy":"",
+ "settings":{
+ "brave":{"enabled":true,"max_results":7,"api_keys":["brave-new-1","brave-new-2","brave-new-1"]}
+ }
+ }`),
+ )
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ updated, err = config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := updated.Tools.Web.Brave.APIKeys.Values(); len(got) != 2 ||
+ got[0] != "brave-new-1" || got[1] != "brave-new-2" {
+ t.Fatalf("brave api keys should be replaced by api_keys, got %#v", got)
+ }
+}
+
+func TestResolveCurrentWebSearchProvider_PrefersConfiguredProvidersBeforeSogou(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Tools.Web.Provider = "auto"
+ cfg.Tools.Web.Sogou.Enabled = true
+ cfg.Tools.Web.Brave.Enabled = true
+ cfg.Tools.Web.Brave.SetAPIKey("brave-test-key")
+
+ if got := resolveCurrentWebSearchProvider(cfg); got != "brave" {
+ t.Fatalf("resolveCurrentWebSearchProvider() = %q, want brave", got)
+ }
+}
+
+func TestResolveCurrentWebSearchProvider_FallsBackWhenExplicitProviderUnavailable(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Tools.Web.Provider = "brave"
+ cfg.Tools.Web.Brave.Enabled = true
+ cfg.Tools.Web.Sogou.Enabled = true
+
+ if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" {
+ t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got)
+ }
+}
+
+func TestResolveCurrentWebSearchProvider_FallsBackWhenProviderIsUnknown(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Tools.Web.Provider = "totally_unknown"
+ cfg.Tools.Web.Sogou.Enabled = true
+
+ if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" {
+ t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got)
+ }
+}
+
+func TestResolveCurrentWebSearchProvider_PrefersStableDefaultForSogouAndDuckDuckGo(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Tools.Web.Provider = "auto"
+ cfg.Tools.Web.Sogou.Enabled = true
+ cfg.Tools.Web.DuckDuckGo.Enabled = true
+
+ if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" {
+ t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got)
+ }
+}
+
+func TestResolveCurrentWebSearchProvider_IgnoresPreferNativeInConfigView(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "custom-default",
+ Model: "openai/gpt-4o",
+ APIKeys: config.SimpleSecureStrings("sk-default"),
+ }}
+ cfg.Agents.Defaults.ModelName = "custom-default"
+ cfg.Tools.Web.PreferNative = true
+ cfg.Tools.Web.Provider = "brave"
+ cfg.Tools.Web.Sogou.Enabled = false
+ cfg.Tools.Web.DuckDuckGo.Enabled = false
+ cfg.Tools.Web.Brave.Enabled = true
+
+ if got := resolveCurrentWebSearchProvider(cfg); got != "" {
+ t.Fatalf("resolveCurrentWebSearchProvider() = %q, want empty when only native search would be available", got)
+ }
}
diff --git a/web/backend/api/update.go b/web/backend/api/update.go
new file mode 100644
index 000000000..2ba862631
--- /dev/null
+++ b/web/backend/api/update.go
@@ -0,0 +1,52 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/sipeed/picoclaw/pkg/updater"
+)
+
+// registerUpdateRoutes registers the self-update endpoint.
+func (h *Handler) registerUpdateRoutes(mux *http.ServeMux) {
+ mux.HandleFunc("/api/update", h.handleUpdate)
+}
+
+type updateRequest struct {
+ URL string `json:"url,omitempty"`
+ Binary string `json:"binary,omitempty"`
+}
+
+type updateResponse struct {
+ Status string `json:"status"`
+ Message string `json:"message,omitempty"`
+}
+
+func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ _ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: "method not allowed"})
+ return
+ }
+
+ dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
+ var req updateRequest
+ if err := dec.Decode(&req); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: "invalid request body"})
+ return
+ }
+
+ binary := req.Binary
+ if binary == "" {
+ binary = "picoclaw-launcher"
+ }
+
+ if err := updater.UpdateSelfFromRelease(req.URL, "", "", binary); err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ _ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: err.Error()})
+ return
+ }
+
+ _ = json.NewEncoder(w).Encode(updateResponse{Status: "ok", Message: "update applied; restart to use new version"})
+}
diff --git a/web/backend/api/version.go b/web/backend/api/version.go
new file mode 100644
index 000000000..6232b989b
--- /dev/null
+++ b/web/backend/api/version.go
@@ -0,0 +1,345 @@
+package api
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os/exec"
+ "regexp"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/web/backend/utils"
+)
+
+type systemVersionResponse struct {
+ Version string `json:"version"`
+ GitCommit string `json:"git_commit,omitempty"`
+ BuildTime string `json:"build_time,omitempty"`
+ GoVersion string `json:"go_version"`
+}
+
+type cachedSystemVersion struct {
+ value systemVersionResponse
+ gatewayPID int
+}
+
+type systemVersionCache struct {
+ mu sync.Mutex
+ current cachedSystemVersion
+ hasCurrent bool
+ inflightCh chan struct{}
+}
+
+func newSystemVersionCache() *systemVersionCache {
+ return &systemVersionCache{}
+}
+
+var (
+ // 15 seconds matches the gateway startup window used elsewhere in launcher flow,
+ // giving slow/embedded hosts enough time for first command invocation while
+ // staying independent from cross-file init ordering.
+ versionCmdTimeout = 15 * time.Second
+ maxVersionResolveAttempts = 3
+ findPicoclawBinaryForInfo = resolveGatewayBinaryForVersionInfo
+ runPicoclawVersionOutput = executePicoclawVersion
+ currentGatewayVersionState = gatewayVersionState
+ launcherBuildInfoForVersion = fallbackSystemVersionInfoFromConfig
+ versionInfoCache = newSystemVersionCache()
+ ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)
+ versionLinePattern = regexp.MustCompile(
+ `^(?:[^A-Za-z0-9]*\s*)?picoclaw(?:\.exe)?\s+([^\s(]+)` +
+ `(?:\s+\(git:\s*([^)]+)\))?\s*$`,
+ )
+)
+
+func (h *Handler) registerVersionRoutes(mux *http.ServeMux) {
+ mux.HandleFunc("GET /api/system/version", h.handleGetVersion)
+}
+
+// handleGetVersion returns runtime version information for web clients.
+func (h *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
+ versionInfo := h.resolveSystemVersionInfo(r.Context())
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(versionInfo); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ return
+ }
+}
+
+// resolveSystemVersionInfo prefers the actual picoclaw binary version output,
+// and falls back to launcher build metadata when command execution fails.
+func (h *Handler) resolveSystemVersionInfo(ctx context.Context) systemVersionResponse {
+ for range maxVersionResolveAttempts {
+ gatewayPID, gatewayAlive := currentGatewayVersionState()
+ if cached, ok := versionInfoCache.get(gatewayPID, gatewayAlive); ok {
+ return cached
+ }
+
+ leader, ok := versionInfoCache.waitOrStart(ctx)
+ if !ok {
+ return fallbackSystemVersionInfo()
+ }
+ if !leader {
+ continue
+ }
+
+ resolved := h.resolveSystemVersionInfoUncached(ctx)
+ gatewayPID, gatewayAlive = currentGatewayVersionState()
+ versionInfoCache.finishResolve(resolved, gatewayPID, gatewayAlive)
+ return resolved
+ }
+
+ return fallbackSystemVersionInfo()
+}
+
+func (h *Handler) resolveSystemVersionInfoUncached(ctx context.Context) systemVersionResponse {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ fallback := fallbackSystemVersionInfo()
+
+ execPath := strings.TrimSpace(findPicoclawBinaryForInfo())
+ if execPath == "" {
+ return fallback
+ }
+
+ cmdCtx, cancel := context.WithTimeout(ctx, versionCmdTimeout)
+ defer cancel()
+
+ output, err := runPicoclawVersionOutput(cmdCtx, execPath)
+ if err != nil {
+ return fallback
+ }
+
+ parsed, ok := parsePicoclawVersionOutput(output)
+ if !ok {
+ return fallback
+ }
+
+ if parsed.GoVersion == "" {
+ parsed.GoVersion = fallback.GoVersion
+ if parsed.GoVersion == "" {
+ parsed.GoVersion = runtime.Version()
+ }
+ }
+
+ return parsed
+}
+
+func fallbackSystemVersionInfo() systemVersionResponse {
+ return launcherBuildInfoForVersion()
+}
+
+func fallbackSystemVersionInfoFromConfig() systemVersionResponse {
+ buildTime, goVer := config.FormatBuildInfo()
+ return systemVersionResponse{
+ Version: config.GetVersion(),
+ GitCommit: config.GitCommit,
+ BuildTime: buildTime,
+ GoVersion: goVer,
+ }
+}
+
+// resolveGatewayBinaryForVersionInfo uses the same executable as the launcher
+// gateway start path when available, then falls back to launcher binary lookup.
+// This keeps version probing aligned with the actual gateway startup behavior,
+// so web and gateway do not drift onto different binaries.
+func resolveGatewayBinaryForVersionInfo() string {
+ gateway.mu.Lock()
+ cmd := gateway.cmd
+ gateway.mu.Unlock()
+
+ if cmd != nil {
+ if execPath := strings.TrimSpace(cmd.Path); execPath != "" {
+ return execPath
+ }
+ }
+
+ return utils.FindPicoclawBinary()
+}
+
+func gatewayVersionState() (int, bool) {
+ gateway.mu.Lock()
+ defer gateway.mu.Unlock()
+
+ if gateway.cmd == nil || gateway.cmd.Process == nil {
+ return 0, false
+ }
+ pid := gateway.cmd.Process.Pid
+ if pid <= 0 {
+ return 0, false
+ }
+
+ return pid, isCmdProcessAliveLocked(gateway.cmd)
+}
+
+func (c *systemVersionCache) get(gatewayPID int, gatewayAlive bool) (systemVersionResponse, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if c.hasCurrent && (!gatewayAlive || gatewayPID <= 0 || gatewayPID != c.current.gatewayPID) {
+ c.clearCurrentLocked()
+ }
+
+ if c.hasCurrent {
+ return c.current.value, true
+ }
+
+ return systemVersionResponse{}, false
+}
+
+func (c *systemVersionCache) waitOrStart(ctx context.Context) (bool, bool) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if ctx.Err() != nil {
+ return false, false
+ }
+
+ c.mu.Lock()
+ if c.inflightCh == nil {
+ c.inflightCh = make(chan struct{})
+ c.mu.Unlock()
+ return true, true
+ }
+ waitCh := c.inflightCh
+ c.mu.Unlock()
+
+ select {
+ case <-waitCh:
+ return false, true
+ case <-ctx.Done():
+ return false, false
+ }
+}
+
+func (c *systemVersionCache) finishResolve(value systemVersionResponse, gatewayPID int, gatewayAlive bool) {
+ c.mu.Lock()
+ if gatewayAlive && gatewayPID > 0 {
+ c.current = cachedSystemVersion{value: value, gatewayPID: gatewayPID}
+ c.hasCurrent = true
+ } else {
+ c.clearCurrentLocked()
+ }
+
+ inflightCh := c.inflightCh
+ c.inflightCh = nil
+ c.mu.Unlock()
+
+ if inflightCh != nil {
+ close(inflightCh)
+ }
+}
+
+func (c *systemVersionCache) clearCurrentLocked() {
+ c.hasCurrent = false
+ c.current = cachedSystemVersion{}
+}
+
+func (c *systemVersionCache) resetForTest() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ c.current = cachedSystemVersion{}
+ c.hasCurrent = false
+ if c.inflightCh != nil {
+ close(c.inflightCh)
+ c.inflightCh = nil
+ }
+}
+
+// executePicoclawVersion runs the version subcommand against the
+// discovered picoclaw executable.
+func executePicoclawVersion(ctx context.Context, execPath string) (string, error) {
+ out, err := exec.CommandContext(ctx, execPath, "version").CombinedOutput()
+ if err == nil {
+ return string(out), nil
+ }
+
+ return string(out), fmt.Errorf("failed to execute version command: %w", err)
+}
+
+// parsePicoclawVersionOutput extracts version/build/go fields from CLI output.
+// It accepts banner/ANSI-decorated output and only requires the version line.
+func parsePicoclawVersionOutput(raw string) (systemVersionResponse, bool) {
+ var result systemVersionResponse
+
+ scanner := bufio.NewScanner(strings.NewReader(raw))
+ for scanner.Scan() {
+ line := strings.TrimSpace(ansiEscapePattern.ReplaceAllString(scanner.Text(), ""))
+ if line == "" {
+ continue
+ }
+
+ if match := versionLinePattern.FindStringSubmatch(line); len(match) > 0 {
+ candidateVersion := strings.TrimSpace(match[1])
+ if !isLikelyVersionValue(candidateVersion) {
+ continue
+ }
+ result.Version = candidateVersion
+ if len(match) > 2 {
+ result.GitCommit = strings.TrimSpace(match[2])
+ }
+ continue
+ }
+
+ if buildValue, ok := strings.CutPrefix(line, "Build:"); ok {
+ result.BuildTime = strings.TrimSpace(buildValue)
+ continue
+ }
+
+ if goValue, ok := strings.CutPrefix(line, "Go:"); ok {
+ result.GoVersion = strings.TrimSpace(goValue)
+ }
+ }
+
+ if err := scanner.Err(); err != nil {
+ return systemVersionResponse{}, false
+ }
+
+ if result.Version == "" {
+ return systemVersionResponse{}, false
+ }
+
+ return result, true
+}
+
+func isLikelyVersionValue(value string) bool {
+ v := strings.TrimSpace(strings.ToLower(value))
+ if v == "" {
+ return false
+ }
+ if v == "dev" {
+ return true
+ }
+
+ // Accept git-like short/long hashes even when they contain only letters (a-f).
+ if len(v) >= 7 && len(v) <= 40 {
+ allHex := true
+ for _, ch := range v {
+ if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') {
+ continue
+ }
+ allHex = false
+ break
+ }
+ if allHex {
+ return true
+ }
+ }
+
+ for _, ch := range v {
+ if ch >= '0' && ch <= '9' {
+ return true
+ }
+ }
+ return false
+}
diff --git a/web/backend/api/version_test.go b/web/backend/api/version_test.go
new file mode 100644
index 000000000..31c5366ab
--- /dev/null
+++ b/web/backend/api/version_test.go
@@ -0,0 +1,317 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os/exec"
+ "runtime"
+ "testing"
+)
+
+func setupVersionTestIsolation(t *testing.T) {
+ t.Helper()
+
+ originalGatewayState := currentGatewayVersionState
+ originalFinder := findPicoclawBinaryForInfo
+ originalRunner := runPicoclawVersionOutput
+ originalFallback := launcherBuildInfoForVersion
+ t.Cleanup(func() {
+ currentGatewayVersionState = originalGatewayState
+ findPicoclawBinaryForInfo = originalFinder
+ runPicoclawVersionOutput = originalRunner
+ launcherBuildInfoForVersion = originalFallback
+ versionInfoCache.resetForTest()
+ })
+
+ currentGatewayVersionState = func() (int, bool) { return 0, false }
+ versionInfoCache.resetForTest()
+}
+
+func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ launcherBuildInfoForVersion = func() systemVersionResponse {
+ return systemVersionResponse{Version: "fallback", GoVersion: "go-fallback"}
+ }
+
+ findPicoclawBinaryForInfo = func() string { return "picoclaw" }
+ runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
+ return "🦞 picoclaw v1.2.3 (git: deadbeef)\n Build: 2026-03-27T12:34:56Z\n Go: go1.25.8\n", nil
+ }
+
+ h := NewHandler("")
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var got systemVersionResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+
+ if got.Version != "v1.2.3" {
+ t.Fatalf("version = %q, want %q", got.Version, "v1.2.3")
+ }
+ if got.GitCommit != "deadbeef" {
+ t.Fatalf("git_commit = %q, want %q", got.GitCommit, "deadbeef")
+ }
+ if got.BuildTime != "2026-03-27T12:34:56Z" {
+ t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T12:34:56Z")
+ }
+ if got.GoVersion != "go1.25.8" {
+ t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8")
+ }
+}
+
+func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ expected := systemVersionResponse{
+ Version: "v9.9.9",
+ GitCommit: "cafebabe",
+ BuildTime: "2026-03-27T10:43:34+0000",
+ GoVersion: "go1.25.8",
+ }
+ launcherBuildInfoForVersion = func() systemVersionResponse { return expected }
+
+ findPicoclawBinaryForInfo = func() string { return "picoclaw" }
+ runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
+ return "", errors.New("binary unavailable")
+ }
+
+ h := NewHandler("")
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var got systemVersionResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+
+ if got.Version != expected.Version {
+ t.Fatalf("version = %q, want %q", got.Version, expected.Version)
+ }
+ if got.GitCommit != expected.GitCommit {
+ t.Fatalf("git_commit = %q, want %q", got.GitCommit, expected.GitCommit)
+ }
+ if got.BuildTime != expected.BuildTime {
+ t.Fatalf("build_time = %q, want %q", got.BuildTime, expected.BuildTime)
+ }
+ if got.GoVersion != expected.GoVersion {
+ t.Fatalf("go_version = %q, want %q", got.GoVersion, expected.GoVersion)
+ }
+}
+
+func TestParsePicoclawVersionOutput(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ raw := "\u001b[1;31m████\u001b[0m\n🦞 picoclaw 18ec263 (git: 18ec2631)\n Build: 2026-03-27T10:43:34+0000\n Go: go1.25.8\n"
+ got, ok := parsePicoclawVersionOutput(raw)
+ if !ok {
+ t.Fatal("parsePicoclawVersionOutput() should parse valid output")
+ }
+ if got.Version != "18ec263" {
+ t.Fatalf("version = %q, want %q", got.Version, "18ec263")
+ }
+ if got.GitCommit != "18ec2631" {
+ t.Fatalf("git_commit = %q, want %q", got.GitCommit, "18ec2631")
+ }
+ if got.BuildTime != "2026-03-27T10:43:34+0000" {
+ t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T10:43:34+0000")
+ }
+ if got.GoVersion != "go1.25.8" {
+ t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8")
+ }
+}
+
+func TestParsePicoclawVersionOutputIgnoresUsageLine(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ raw := "Usage: picoclaw version [flags]\n"
+ got, ok := parsePicoclawVersionOutput(raw)
+ if ok {
+ t.Fatalf("parsePicoclawVersionOutput() parsed usage line unexpectedly: %#v", got)
+ }
+}
+
+func TestParsePicoclawVersionOutputAcceptsLetterOnlyHashVersion(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ raw := "picoclaw abcdefa (git: abcdefabcdefabcdefabcdefabcdefabcdefabcd)\n"
+ got, ok := parsePicoclawVersionOutput(raw)
+ if !ok {
+ t.Fatal("parsePicoclawVersionOutput() should parse letter-only hash version")
+ }
+ if got.Version != "abcdefa" {
+ t.Fatalf("version = %q, want %q", got.Version, "abcdefa")
+ }
+ if got.GitCommit != "abcdefabcdefabcdefabcdefabcdefabcdefabcd" {
+ t.Fatalf("git_commit = %q, want %q", got.GitCommit, "abcdefabcdefabcdefabcdefabcdefabcdefabcd")
+ }
+}
+
+func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ launcherBuildInfoForVersion = func() systemVersionResponse {
+ return systemVersionResponse{Version: "dev", GoVersion: ""}
+ }
+
+ findPicoclawBinaryForInfo = func() string { return "picoclaw" }
+ runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
+ return "picoclaw v1.0.0\n", nil
+ }
+
+ h := NewHandler("")
+ got := h.resolveSystemVersionInfo(context.Background())
+ if got.GoVersion != runtime.Version() {
+ t.Fatalf("go_version = %q, want runtime version %q", got.GoVersion, runtime.Version())
+ }
+}
+
+func TestResolveSystemVersionInfoCachesWhileGatewayAlive(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ launcherBuildInfoForVersion = func() systemVersionResponse {
+ return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"}
+ }
+ findPicoclawBinaryForInfo = func() string { return "picoclaw" }
+
+ pid := 4321
+ currentGatewayVersionState = func() (int, bool) { return pid, true }
+
+ runCount := 0
+ runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
+ runCount++
+ return fmt.Sprintf("picoclaw v1.2.%d\n", runCount), nil
+ }
+
+ h := NewHandler("")
+ first := h.resolveSystemVersionInfo(context.Background())
+ second := h.resolveSystemVersionInfo(context.Background())
+
+ if first.Version != "v1.2.1" {
+ t.Fatalf("first version = %q, want %q", first.Version, "v1.2.1")
+ }
+ if second.Version != "v1.2.1" {
+ t.Fatalf("second version = %q, want cached %q", second.Version, "v1.2.1")
+ }
+ if runCount != 1 {
+ t.Fatalf("run count = %d, want %d", runCount, 1)
+ }
+}
+
+func TestResolveSystemVersionInfoInvalidatesCacheWhenGatewayStops(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ launcherBuildInfoForVersion = func() systemVersionResponse {
+ return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"}
+ }
+ findPicoclawBinaryForInfo = func() string { return "picoclaw" }
+
+ alive := true
+ pid := 9876
+ currentGatewayVersionState = func() (int, bool) {
+ if !alive {
+ return 0, false
+ }
+ return pid, true
+ }
+
+ runCount := 0
+ runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
+ runCount++
+ return fmt.Sprintf("picoclaw v2.0.%d\n", runCount), nil
+ }
+
+ h := NewHandler("")
+ first := h.resolveSystemVersionInfo(context.Background())
+ second := h.resolveSystemVersionInfo(context.Background())
+
+ if first.Version != "v2.0.1" || second.Version != "v2.0.1" {
+ t.Fatalf("expected cached version v2.0.1, got first=%q second=%q", first.Version, second.Version)
+ }
+ if runCount != 1 {
+ t.Fatalf("run count after cache hit = %d, want %d", runCount, 1)
+ }
+
+ alive = false
+ third := h.resolveSystemVersionInfo(context.Background())
+ if third.Version != "v2.0.2" {
+ t.Fatalf("third version = %q, want refreshed %q", third.Version, "v2.0.2")
+ }
+ if runCount != 2 {
+ t.Fatalf("run count after invalidation = %d, want %d", runCount, 2)
+ }
+}
+
+func TestResolveSystemVersionInfoSkipsCommandWhenContextCanceled(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ launcherBuildInfoForVersion = func() systemVersionResponse {
+ return systemVersionResponse{Version: "v3.0.0", GoVersion: "go-fallback"}
+ }
+ findPicoclawBinaryForInfo = func() string { return "picoclaw" }
+
+ runCount := 0
+ runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
+ runCount++
+ return "picoclaw v9.9.9\n", nil
+ }
+
+ canceledCtx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ h := NewHandler("")
+ got := h.resolveSystemVersionInfo(canceledCtx)
+
+ if runCount != 0 {
+ t.Fatalf("run count = %d, want %d", runCount, 0)
+ }
+ if got.Version != "v3.0.0" {
+ t.Fatalf("version = %q, want fallback %q", got.Version, "v3.0.0")
+ }
+}
+
+func TestResolveGatewayBinaryForVersionInfoPrefersGatewayCommandPath(t *testing.T) {
+ setupVersionTestIsolation(t)
+
+ originalFinder := findPicoclawBinaryForInfo
+ t.Cleanup(func() {
+ findPicoclawBinaryForInfo = originalFinder
+ })
+
+ gateway.mu.Lock()
+ originalCmd := gateway.cmd
+ gateway.cmd = &exec.Cmd{Path: "/tmp/picoclaw-from-gateway"}
+ gateway.mu.Unlock()
+ t.Cleanup(func() {
+ gateway.mu.Lock()
+ gateway.cmd = originalCmd
+ gateway.mu.Unlock()
+ })
+
+ got := resolveGatewayBinaryForVersionInfo()
+ if got != "/tmp/picoclaw-from-gateway" {
+ t.Fatalf("exec path = %q, want %q", got, "/tmp/picoclaw-from-gateway")
+ }
+}
diff --git a/web/backend/api/wecom.go b/web/backend/api/wecom.go
index 7dcec9f49..74e5d8e83 100644
--- a/web/backend/api/wecom.go
+++ b/web/backend/api/wecom.go
@@ -216,11 +216,19 @@ func (h *Handler) saveWecomBinding(botID, secret string) error {
return fmt.Errorf("load config: %w", err)
}
- cfg.Channels.WeCom.Enabled = true
- cfg.Channels.WeCom.BotID = botID
- cfg.Channels.WeCom.SetSecret(secret)
- if strings.TrimSpace(cfg.Channels.WeCom.WebSocketURL) == "" {
- cfg.Channels.WeCom.WebSocketURL = wecomDefaultWebSocketURL
+ bc := cfg.Channels.Get(config.ChannelWeCom)
+ if bc == nil {
+ bc = &config.Channel{Type: config.ChannelWeCom}
+ cfg.Channels["wecom"] = bc
+ }
+ bc.Enabled = true
+
+ var wecomCfg config.WeComSettings
+ bc.Decode(&wecomCfg)
+ wecomCfg.BotID = botID
+ wecomCfg.Secret = *config.NewSecureString(secret)
+ if strings.TrimSpace(wecomCfg.WebSocketURL) == "" {
+ wecomCfg.WebSocketURL = wecomDefaultWebSocketURL
}
if err := config.SaveConfig(h.configPath, cfg); err != nil {
return err
diff --git a/web/backend/api/weixin.go b/web/backend/api/weixin.go
index 808b88c41..888789f86 100644
--- a/web/backend/api/weixin.go
+++ b/web/backend/api/weixin.go
@@ -210,11 +210,26 @@ func (h *Handler) saveWeixinBinding(token, accountID string) error {
if err != nil {
return fmt.Errorf("load config: %w", err)
}
- cfg.Channels.Weixin.SetToken(token)
- cfg.Channels.Weixin.Enabled = true
- if accountID != "" {
- cfg.Channels.Weixin.AccountID = accountID
+
+ bc := cfg.Channels.Get(config.ChannelWeixin)
+ if bc == nil {
+ bc = &config.Channel{Type: config.ChannelWeixin}
+ cfg.Channels[config.ChannelWeixin] = bc
}
+ bc.Enabled = true
+
+ var weixinCfg config.WeixinSettings
+ if err := bc.Decode(&weixinCfg); err != nil {
+ logger.ErrorCF("weixin", "failed to decode weixin settings", map[string]any{
+ "error": err.Error(),
+ })
+ return fmt.Errorf("decode weixin settings: %w", err)
+ }
+ weixinCfg.Token = *config.NewSecureString(token)
+ if accountID != "" {
+ weixinCfg.AccountID = accountID
+ }
+
if err := config.SaveConfig(h.configPath, cfg); err != nil {
return err
}
diff --git a/web/backend/api/weixin_test.go b/web/backend/api/weixin_test.go
index ce54eec16..575de7b9c 100644
--- a/web/backend/api/weixin_test.go
+++ b/web/backend/api/weixin_test.go
@@ -44,13 +44,19 @@ func TestSaveWeixinBindingReturnsSuccessWhenRestartFails(t *testing.T) {
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
- if got := savedCfg.Channels.Weixin.Token.String(); got != "bot-token" {
+ bc := savedCfg.Channels["weixin"]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ t.Fatalf("GetDecoded() error = %v", err)
+ }
+ wxCfg := decoded.(*config.WeixinSettings)
+ if got := wxCfg.Token.String(); got != "bot-token" {
t.Fatalf("Weixin.Token() = %q, want %q", got, "bot-token")
}
- if got := savedCfg.Channels.Weixin.AccountID; got != "bot-account" {
+ if got := wxCfg.AccountID; got != "bot-account" {
t.Fatalf("Weixin.AccountID = %q, want %q", got, "bot-account")
}
- if !savedCfg.Channels.Weixin.Enabled {
+ if !bc.Enabled {
t.Fatalf("Weixin.Enabled = false, want true")
}
}
diff --git a/web/backend/app_runtime.go b/web/backend/app_runtime.go
index ab564db2c..a06396526 100644
--- a/web/backend/app_runtime.go
+++ b/web/backend/app_runtime.go
@@ -34,22 +34,30 @@ func shutdownApp() {
apiHandler.Shutdown()
}
- if server != nil {
- // Disable keep-alive to allow graceful shutdown
- server.SetKeepAlivesEnabled(false)
-
- ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
- defer cancel()
- if err := server.Shutdown(ctx); err != nil {
- // Context deadline exceeded is expected if there are active connections
- // This is not necessarily an error, so log it at info level
- if errors.Is(err, context.DeadlineExceeded) {
- logger.Infof("Server shutdown timeout after %v, forcing close", shutdownTimeout)
- } else {
- logger.Errorf("Server shutdown error: %v", err)
+ if len(servers) > 0 {
+ for _, srv := range servers {
+ if srv == nil {
+ continue
+ }
+
+ // Disable keep-alive to allow graceful shutdown
+ srv.SetKeepAlivesEnabled(false)
+
+ ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
+ err := srv.Shutdown(ctx)
+ cancel()
+
+ if err != nil {
+ // Context deadline exceeded is expected if there are active connections
+ // This is not necessarily an error, so log it at info level
+ if errors.Is(err, context.DeadlineExceeded) {
+ logger.Infof("Server shutdown timeout after %v, forcing close", shutdownTimeout)
+ } else {
+ logger.Errorf("Server shutdown error: %v", err)
+ }
+ } else {
+ logger.Infof("Server shutdown completed successfully")
}
- } else {
- logger.Infof("Server shutdown completed successfully")
}
}
}
diff --git a/web/backend/dashboardauth/platform.go b/web/backend/dashboardauth/platform.go
new file mode 100644
index 000000000..25ba5da08
--- /dev/null
+++ b/web/backend/dashboardauth/platform.go
@@ -0,0 +1,7 @@
+package dashboardauth
+
+import "errors"
+
+// ErrUnsupportedPlatform reports that the SQLite-backed password store is not
+// available for the current target platform.
+var ErrUnsupportedPlatform = errors.New("dashboard password store is unavailable on this platform")
diff --git a/web/backend/dashboardauth/sql.go b/web/backend/dashboardauth/sql.go
new file mode 100644
index 000000000..94886072b
--- /dev/null
+++ b/web/backend/dashboardauth/sql.go
@@ -0,0 +1,24 @@
+package dashboardauth
+
+const (
+ // DBFilename is the SQLite database file stored under the PicoClaw home directory.
+ DBFilename = "launcher-auth.db"
+
+ sqliteDriver = "sqlite"
+ // bcryptCost is deliberately high enough to slow brute-force attempts.
+ bcryptCost = 12
+
+ sqlCreateTable = `
+ CREATE TABLE IF NOT EXISTS dashboard_credentials (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ bcrypt_hash TEXT NOT NULL
+ )`
+
+ sqlCountCredentials = `SELECT COUNT(*) FROM dashboard_credentials WHERE id = 1`
+
+ sqlUpsertHash = `
+ INSERT INTO dashboard_credentials (id, bcrypt_hash) VALUES (1, ?)
+ ON CONFLICT(id) DO UPDATE SET bcrypt_hash = excluded.bcrypt_hash`
+
+ sqlSelectHash = `SELECT bcrypt_hash FROM dashboard_credentials WHERE id = 1`
+)
diff --git a/web/backend/dashboardauth/store.go b/web/backend/dashboardauth/store.go
new file mode 100644
index 000000000..870796bba
--- /dev/null
+++ b/web/backend/dashboardauth/store.go
@@ -0,0 +1,96 @@
+//go:build !mipsle && !netbsd && !(freebsd && arm)
+
+// Package dashboardauth provides a bcrypt-backed SQLite store for the
+// launcher dashboard password. The database contains a single row (id=1)
+// with the bcrypt hash; no plaintext is ever persisted.
+package dashboardauth
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "path/filepath"
+
+ "golang.org/x/crypto/bcrypt"
+ _ "modernc.org/sqlite" // register "sqlite" driver
+)
+
+// Store holds a handle to the SQLite database that stores the bcrypt hash.
+type Store struct {
+ db *sql.DB
+ path string // absolute path to the SQLite file
+}
+
+// New opens (or creates) the database inside dir, using the package's
+// canonical filename. This is the preferred constructor for most callers.
+// Any error is wrapped with the resolved path so callers get actionable output.
+func New(dir string) (*Store, error) {
+ path := filepath.Join(dir, DBFilename)
+ s, err := Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("open %q: %w", path, err)
+ }
+ return s, nil
+}
+
+// Open opens (or creates) the SQLite database at path and migrates the schema.
+func Open(path string) (*Store, error) {
+ db, err := sql.Open(sqliteDriver, path)
+ if err != nil {
+ return nil, err
+ }
+ if _, err = db.Exec(sqlCreateTable); err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+ return &Store{db: db, path: path}, nil
+}
+
+// Close releases the database handle.
+func (s *Store) Close() error { return s.db.Close() }
+
+// DBPath returns the absolute path to the SQLite database file.
+func (s *Store) DBPath() string { return s.path }
+
+// IsInitialized reports whether a password hash has been stored.
+func (s *Store) IsInitialized(ctx context.Context) (bool, error) {
+ var n int
+ err := s.db.QueryRowContext(ctx, sqlCountCredentials).Scan(&n)
+ if err != nil {
+ return false, err
+ }
+ return n > 0, nil
+}
+
+// SetPassword hashes plain with bcrypt (cost 12) and stores (or replaces) it.
+// The plaintext is never written to disk.
+func (s *Store) SetPassword(ctx context.Context, plain string) error {
+ if len([]rune(plain)) == 0 {
+ return errors.New("password must not be empty")
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx, sqlUpsertHash, string(hash))
+ return err
+}
+
+// VerifyPassword returns true iff plain matches the stored bcrypt hash.
+// Returns (false, nil) when no password has been set yet.
+func (s *Store) VerifyPassword(ctx context.Context, plain string) (bool, error) {
+ var hash string
+ err := s.db.QueryRowContext(ctx, sqlSelectHash).Scan(&hash)
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
+ if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
+ return false, nil
+ }
+ return err == nil, err
+}
diff --git a/web/backend/dashboardauth/store_unsupported.go b/web/backend/dashboardauth/store_unsupported.go
new file mode 100644
index 000000000..204682020
--- /dev/null
+++ b/web/backend/dashboardauth/store_unsupported.go
@@ -0,0 +1,60 @@
+//go:build mipsle || netbsd || (freebsd && arm)
+
+package dashboardauth
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "runtime"
+)
+
+// Store is unavailable on platforms where modernc sqlite/libc does not build.
+type Store struct {
+ path string
+}
+
+// New reports that the password store is unavailable on this platform.
+func New(dir string) (*Store, error) {
+ path := filepath.Join(dir, DBFilename)
+ s, err := Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("open %q: %w", path, err)
+ }
+ return s, nil
+}
+
+// Open reports that the password store is unavailable on this platform.
+func Open(path string) (*Store, error) {
+ return nil, unsupportedPlatformError()
+}
+
+// Close is a no-op for unsupported platforms.
+func (s *Store) Close() error { return nil }
+
+// DBPath returns the configured path, if any.
+func (s *Store) DBPath() string {
+ if s == nil {
+ return ""
+ }
+ return s.path
+}
+
+// IsInitialized reports that the store is unavailable on this platform.
+func (s *Store) IsInitialized(context.Context) (bool, error) {
+ return false, unsupportedPlatformError()
+}
+
+// SetPassword reports that the store is unavailable on this platform.
+func (s *Store) SetPassword(context.Context, string) error {
+ return unsupportedPlatformError()
+}
+
+// VerifyPassword reports that the store is unavailable on this platform.
+func (s *Store) VerifyPassword(context.Context, string) (bool, error) {
+ return false, unsupportedPlatformError()
+}
+
+func unsupportedPlatformError() error {
+ return fmt.Errorf("%w (%s/%s)", ErrUnsupportedPlatform, runtime.GOOS, runtime.GOARCH)
+}
diff --git a/web/backend/i18n.go b/web/backend/i18n.go
index 106df8506..9cda9e5d5 100644
--- a/web/backend/i18n.go
+++ b/web/backend/i18n.go
@@ -24,8 +24,6 @@ const (
AppTooltip TranslationKey = "AppTooltip"
MenuOpen TranslationKey = "MenuOpen"
MenuOpenTooltip TranslationKey = "MenuOpenTooltip"
- MenuCopyToken TranslationKey = "MenuCopyToken"
- MenuCopyTokenHint TranslationKey = "MenuCopyTokenHint"
MenuAbout TranslationKey = "MenuAbout"
MenuAboutTooltip TranslationKey = "MenuAboutTooltip"
MenuVersion TranslationKey = "MenuVersion"
@@ -49,8 +47,6 @@ var translations = map[Language]map[TranslationKey]string{
AppTooltip: "%s - Web Console",
MenuOpen: "Open Console",
MenuOpenTooltip: "Open PicoClaw console in browser",
- MenuCopyToken: "Copy dashboard token",
- MenuCopyTokenHint: "Copy the current web console access token to the clipboard",
MenuAbout: "About",
MenuAboutTooltip: "About PicoClaw",
MenuVersion: "Version: %s",
@@ -68,8 +64,6 @@ var translations = map[Language]map[TranslationKey]string{
AppTooltip: "%s - Web Console",
MenuOpen: "打开控制台",
MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台",
- MenuCopyToken: "复制控制台口令",
- MenuCopyTokenHint: "将当前 Web 控制台访问口令复制到剪贴板",
MenuAbout: "关于",
MenuAboutTooltip: "关于 PicoClaw",
MenuVersion: "版本: %s",
diff --git a/web/backend/launcherconfig/config.go b/web/backend/launcherconfig/config.go
index b8465ef74..e3595738f 100644
--- a/web/backend/launcherconfig/config.go
+++ b/web/backend/launcherconfig/config.go
@@ -1,8 +1,6 @@
package launcherconfig
import (
- "crypto/rand"
- "encoding/base64"
"encoding/json"
"fmt"
"net"
@@ -16,18 +14,19 @@ const (
FileName = "launcher-config.json"
// DefaultPort is the default port for the web launcher.
DefaultPort = 18800
-
- // dashboardSigningKeyBytes is the HMAC-SHA256 key size (256 bits).
- dashboardSigningKeyBytes = 32
- // dashboardTokenEntropyBytes is CSPRNG length before base64 for the per-run dashboard token (256 bits).
- dashboardTokenEntropyBytes = 32
+ // EnvLauncherHost overrides launcher listen host.
+ EnvLauncherHost = "PICOCLAW_LAUNCHER_HOST"
)
// Config stores launch parameters for the web backend service.
type Config struct {
- Port int `json:"port"`
- Public bool `json:"public"`
- AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
+ Port int `json:"port"`
+ Public bool `json:"public"`
+ AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
+ DashboardPasswordHash string `json:"dashboard_password_hash,omitempty"`
+ // LegacyLauncherToken is read only for one-time migration from the removed
+ // token login flow. Save always clears it so new configs do not persist it.
+ LegacyLauncherToken string `json:"launcher_token,omitempty"`
}
// Default returns default launcher settings.
@@ -48,34 +47,6 @@ func Validate(cfg Config) error {
return nil
}
-// EnsureDashboardSecrets returns signing key bytes and the effective dashboard token for this
-// process. The signing key is freshly random each call; the token comes from the environment
-// variable PICOCLAW_LAUNCHER_TOKEN when set, otherwise a new random token.
-func EnsureDashboardSecrets() (effectiveToken string, signingKey []byte, newRandomDashboardToken bool, err error) {
- signingKey = make([]byte, dashboardSigningKeyBytes)
- if _, err = rand.Read(signingKey); err != nil {
- return "", nil, false, err
- }
-
- effectiveToken = strings.TrimSpace(os.Getenv("PICOCLAW_LAUNCHER_TOKEN"))
- if effectiveToken != "" {
- return effectiveToken, signingKey, false, nil
- }
- tok, genErr := randomDashboardToken()
- if genErr != nil {
- return "", nil, false, genErr
- }
- return tok, signingKey, true, nil
-}
-
-func randomDashboardToken() (string, error) {
- buf := make([]byte, dashboardTokenEntropyBytes)
- if _, err := rand.Read(buf); err != nil {
- return "", err
- }
- return base64.RawURLEncoding.EncodeToString(buf), nil
-}
-
// NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs.
func NormalizeCIDRs(cidrs []string) []string {
if len(cidrs) == 0 {
@@ -124,6 +95,8 @@ func Load(path string, fallback Config) (Config, error) {
return Config{}, err
}
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
+ cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash)
+ cfg.LegacyLauncherToken = strings.TrimSpace(cfg.LegacyLauncherToken)
if err := Validate(cfg); err != nil {
return Config{}, err
}
@@ -133,6 +106,8 @@ func Load(path string, fallback Config) (Config, error) {
// Save writes launcher settings to disk.
func Save(path string, cfg Config) error {
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
+ cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash)
+ cfg.LegacyLauncherToken = ""
if err := Validate(cfg); err != nil {
return err
}
diff --git a/web/backend/launcherconfig/config_test.go b/web/backend/launcherconfig/config_test.go
index 4e8a54e41..bb13ea115 100644
--- a/web/backend/launcherconfig/config_test.go
+++ b/web/backend/launcherconfig/config_test.go
@@ -1,11 +1,10 @@
package launcherconfig
import (
+ "context"
"os"
"path/filepath"
"testing"
-
- "github.com/sipeed/picoclaw/web/backend/middleware"
)
func TestLoadReturnsFallbackWhenMissing(t *testing.T) {
@@ -25,9 +24,11 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "launcher-config.json")
want := Config{
- Port: 18080,
- Public: true,
- AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"},
+ Port: 18080,
+ Public: true,
+ AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"},
+ DashboardPasswordHash: "$2a$12$saved-dashboard-password-hash",
+ LegacyLauncherToken: "legacy-token-should-not-persist",
}
if err := Save(path, want); err != nil {
@@ -40,6 +41,12 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
if got.Port != want.Port || got.Public != want.Public {
t.Fatalf("Load() = %+v, want %+v", got, want)
}
+ if got.DashboardPasswordHash != want.DashboardPasswordHash {
+ t.Fatalf("dashboard_password_hash = %q, want %q", got.DashboardPasswordHash, want.DashboardPasswordHash)
+ }
+ if got.LegacyLauncherToken != "" {
+ t.Fatalf("legacy launcher_token = %q, want empty after Save", got.LegacyLauncherToken)
+ }
if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) {
t.Fatalf("allowed_cidrs len = %d, want %d", len(got.AllowedCIDRs), len(want.AllowedCIDRs))
}
@@ -58,6 +65,21 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
}
}
+func TestLoadReadsLegacyLauncherTokenForMigration(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "launcher-config.json")
+ if err := os.WriteFile(path, []byte(`{"port":18800,"launcher_token":"legacy-token"}`), 0o600); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ got, err := Load(path, Default())
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ if got.LegacyLauncherToken != "legacy-token" {
+ t.Fatalf("legacy launcher_token = %q, want legacy-token", got.LegacyLauncherToken)
+ }
+}
+
func TestValidateRejectsInvalidPort(t *testing.T) {
if err := Validate(Config{Port: 0, Public: false}); err == nil {
t.Fatal("Validate() expected error for port 0")
@@ -77,51 +99,6 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) {
}
}
-func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) {
- t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "")
-
- tok, key, newTok, err := EnsureDashboardSecrets()
- if err != nil {
- t.Fatalf("EnsureDashboardSecrets() error = %v", err)
- }
- if !newTok || tok == "" || len(key) != dashboardSigningKeyBytes {
- t.Fatalf("unexpected first call: newTok=%v tok=%q keyLen=%d", newTok, tok, len(key))
- }
- mac := middleware.SessionCookieValue(key, tok)
- if mac == "" {
- t.Fatal("empty session mac")
- }
-
- tok2, key2, newTok2, err := EnsureDashboardSecrets()
- if err != nil {
- t.Fatalf("EnsureDashboardSecrets() second error = %v", err)
- }
- if !newTok2 {
- t.Fatal("second call without env should generate another random token")
- }
- if tok2 == tok {
- t.Fatal("expected a new random dashboard token")
- }
- if string(key2) == string(key) {
- t.Fatal("expected a new signing key")
- }
-}
-
-func TestEnsureDashboardSecrets_EnvOverridesGenerated(t *testing.T) {
- t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "env-only-token-override")
-
- tok, _, newTok, err := EnsureDashboardSecrets()
- if err != nil {
- t.Fatalf("EnsureDashboardSecrets() error = %v", err)
- }
- if tok != "env-only-token-override" {
- t.Fatalf("token = %q, want env value", tok)
- }
- if newTok {
- t.Fatal("newRandomDashboardToken should be false when env is set")
- }
-}
-
func TestNormalizeCIDRs(t *testing.T) {
got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"})
want := []string{"192.168.1.0/24", "10.0.0.0/8"}
@@ -134,3 +111,42 @@ func TestNormalizeCIDRs(t *testing.T) {
}
}
}
+
+func TestPasswordStoreSetAndVerify(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "launcher-config.json")
+ store := NewPasswordStore(path, Default())
+ ctx := context.Background()
+
+ initialized, err := store.IsInitialized(ctx)
+ if err != nil {
+ t.Fatalf("IsInitialized() error = %v", err)
+ }
+ if initialized {
+ t.Fatal("IsInitialized() = true, want false before SetPassword")
+ }
+
+ if err = store.SetPassword(ctx, "dashboard-password"); err != nil {
+ t.Fatalf("SetPassword() error = %v", err)
+ }
+ initialized, err = store.IsInitialized(ctx)
+ if err != nil {
+ t.Fatalf("IsInitialized() after SetPassword error = %v", err)
+ }
+ if !initialized {
+ t.Fatal("IsInitialized() = false, want true after SetPassword")
+ }
+ ok, err := store.VerifyPassword(ctx, "dashboard-password")
+ if err != nil {
+ t.Fatalf("VerifyPassword() error = %v", err)
+ }
+ if !ok {
+ t.Fatal("VerifyPassword(correct) = false, want true")
+ }
+ ok, err = store.VerifyPassword(ctx, "wrong-password")
+ if err != nil {
+ t.Fatalf("VerifyPassword(wrong) error = %v", err)
+ }
+ if ok {
+ t.Fatal("VerifyPassword(wrong) = true, want false")
+ }
+}
diff --git a/web/backend/launcherconfig/migration.go b/web/backend/launcherconfig/migration.go
new file mode 100644
index 000000000..66caa73ae
--- /dev/null
+++ b/web/backend/launcherconfig/migration.go
@@ -0,0 +1,62 @@
+package launcherconfig
+
+import (
+ "context"
+ "strings"
+)
+
+var (
+ loadConfigForMigration = Load
+ saveConfigForMigration = Save
+)
+
+type dashboardPasswordStore interface {
+ IsInitialized(ctx context.Context) (bool, error)
+ SetPassword(ctx context.Context, plain string) error
+}
+
+// LegacyLauncherTokenMigrationResult reports the outcome of converting a
+// removed launcher_token value into the current password-based auth flow.
+type LegacyLauncherTokenMigrationResult struct {
+ Migrated bool
+ // CleanupErr is non-nil when password migration succeeded (or was already in
+ // place) but removing launcher_token from launcher-config.json failed.
+ CleanupErr error
+}
+
+// MigrateLegacyLauncherToken converts the removed launcher_token setting into
+// the current password-login store, then removes launcher_token from config.
+func MigrateLegacyLauncherToken(
+ ctx context.Context,
+ store dashboardPasswordStore,
+ launcherPath string,
+ fallback Config,
+) (LegacyLauncherTokenMigrationResult, error) {
+ legacyToken := strings.TrimSpace(fallback.LegacyLauncherToken)
+ if legacyToken == "" || store == nil {
+ return LegacyLauncherTokenMigrationResult{}, nil
+ }
+
+ result := LegacyLauncherTokenMigrationResult{}
+ initialized, err := store.IsInitialized(ctx)
+ if err != nil {
+ return result, err
+ }
+ if !initialized {
+ if err = store.SetPassword(ctx, legacyToken); err != nil {
+ return result, err
+ }
+ result.Migrated = true
+ }
+ result.CleanupErr = cleanupLegacyLauncherTokenConfig(launcherPath, fallback)
+ return result, nil
+}
+
+func cleanupLegacyLauncherTokenConfig(launcherPath string, fallback Config) error {
+ cfg, err := loadConfigForMigration(launcherPath, fallback)
+ if err != nil {
+ return err
+ }
+ cfg.LegacyLauncherToken = ""
+ return saveConfigForMigration(launcherPath, cfg)
+}
diff --git a/web/backend/launcherconfig/migration_test.go b/web/backend/launcherconfig/migration_test.go
new file mode 100644
index 000000000..c5c5fa2c9
--- /dev/null
+++ b/web/backend/launcherconfig/migration_test.go
@@ -0,0 +1,135 @@
+package launcherconfig
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+type stubMigrationPasswordStore struct {
+ initialized bool
+ password string
+}
+
+func (s *stubMigrationPasswordStore) IsInitialized(context.Context) (bool, error) {
+ return s.initialized, nil
+}
+
+func (s *stubMigrationPasswordStore) SetPassword(_ context.Context, plain string) error {
+ s.password = plain
+ s.initialized = true
+ return nil
+}
+
+func TestMigrateLegacyLauncherToken(t *testing.T) {
+ dir := t.TempDir()
+ launcherPath := filepath.Join(dir, FileName)
+ cfg := Config{
+ Port: DefaultPort,
+ LegacyLauncherToken: "legacy-password",
+ }
+ if err := os.WriteFile(
+ launcherPath,
+ []byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"),
+ 0o600,
+ ); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ store := NewPasswordStore(launcherPath, Default())
+ result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg)
+ if err != nil {
+ t.Fatalf("MigrateLegacyLauncherToken() error = %v", err)
+ }
+ if !result.Migrated {
+ t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true")
+ }
+ if result.CleanupErr != nil {
+ t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr)
+ }
+
+ loaded, err := Load(launcherPath, Default())
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ if loaded.LegacyLauncherToken != "" {
+ t.Fatalf("legacy launcher token = %q, want empty", loaded.LegacyLauncherToken)
+ }
+ if loaded.DashboardPasswordHash == "" {
+ t.Fatal("dashboard password hash should be set after migration")
+ }
+ ok, err := store.VerifyPassword(context.Background(), "legacy-password")
+ if err != nil {
+ t.Fatalf("VerifyPassword() error = %v", err)
+ }
+ if !ok {
+ t.Fatal("VerifyPassword() = false, want true")
+ }
+}
+
+func TestMigrateLegacyLauncherTokenCleanupFailureIsNonFatal(t *testing.T) {
+ dir := t.TempDir()
+ launcherPath := filepath.Join(dir, FileName)
+ cfg := Config{
+ Port: DefaultPort,
+ LegacyLauncherToken: "legacy-password",
+ }
+ if err := os.WriteFile(
+ launcherPath,
+ []byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"),
+ 0o600,
+ ); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ store := &stubMigrationPasswordStore{}
+ origSave := saveConfigForMigration
+ saveConfigForMigration = func(string, Config) error {
+ return errors.New("write launcher config")
+ }
+ t.Cleanup(func() {
+ saveConfigForMigration = origSave
+ })
+
+ result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg)
+ if err != nil {
+ t.Fatalf("MigrateLegacyLauncherToken() error = %v, want nil", err)
+ }
+ if !result.Migrated {
+ t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true")
+ }
+ if result.CleanupErr == nil {
+ t.Fatal("MigrateLegacyLauncherToken().CleanupErr = nil, want non-nil")
+ }
+ if store.password != "legacy-password" {
+ t.Fatalf("password = %q, want legacy-password", store.password)
+ }
+
+ loaded, err := Load(launcherPath, Default())
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ if loaded.LegacyLauncherToken != "legacy-password" {
+ t.Fatalf(
+ "legacy launcher token = %q, want legacy-password after cleanup failure",
+ loaded.LegacyLauncherToken,
+ )
+ }
+}
+
+func TestMigrateLegacyLauncherTokenNoopWithoutToken(t *testing.T) {
+ launcherPath := filepath.Join(t.TempDir(), FileName)
+ store := NewPasswordStore(launcherPath, Default())
+ result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, Default())
+ if err != nil {
+ t.Fatalf("MigrateLegacyLauncherToken() error = %v", err)
+ }
+ if result.Migrated {
+ t.Fatal("MigrateLegacyLauncherToken().Migrated = true, want false")
+ }
+ if result.CleanupErr != nil {
+ t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr)
+ }
+}
diff --git a/web/backend/launcherconfig/password_store.go b/web/backend/launcherconfig/password_store.go
new file mode 100644
index 000000000..3813384bb
--- /dev/null
+++ b/web/backend/launcherconfig/password_store.go
@@ -0,0 +1,92 @@
+package launcherconfig
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "sync"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+const passwordBcryptCost = 12
+
+// PasswordStore keeps the dashboard bcrypt hash in launcher-config.json.
+// It is used on platforms where the SQLite-backed dashboard auth store is not
+// available.
+type PasswordStore struct {
+ path string
+ fallback Config
+ mu sync.Mutex
+}
+
+// NewPasswordStore returns a config-backed password store.
+func NewPasswordStore(path string, fallback Config) *PasswordStore {
+ return &PasswordStore{
+ path: path,
+ fallback: fallback,
+ }
+}
+
+// IsInitialized reports whether a dashboard password hash exists in config.
+func (s *PasswordStore) IsInitialized(ctx context.Context) (bool, error) {
+ if err := ctx.Err(); err != nil {
+ return false, err
+ }
+ cfg, err := s.load()
+ if err != nil {
+ return false, err
+ }
+ return strings.TrimSpace(cfg.DashboardPasswordHash) != "", nil
+}
+
+// SetPassword hashes plain with bcrypt and writes it to launcher-config.json.
+func (s *PasswordStore) SetPassword(ctx context.Context, plain string) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if len([]rune(plain)) == 0 {
+ return errors.New("password must not be empty")
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(plain), passwordBcryptCost)
+ if err != nil {
+ return err
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ cfg, err := Load(s.path, s.fallback)
+ if err != nil {
+ return err
+ }
+ cfg.DashboardPasswordHash = string(hash)
+ cfg.LegacyLauncherToken = ""
+ return Save(s.path, cfg)
+}
+
+// VerifyPassword returns true iff plain matches the stored bcrypt hash.
+func (s *PasswordStore) VerifyPassword(ctx context.Context, plain string) (bool, error) {
+ if err := ctx.Err(); err != nil {
+ return false, err
+ }
+ cfg, err := s.load()
+ if err != nil {
+ return false, err
+ }
+ hash := strings.TrimSpace(cfg.DashboardPasswordHash)
+ if hash == "" {
+ return false, nil
+ }
+ err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
+ if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
+ return false, nil
+ }
+ return err == nil, err
+}
+
+func (s *PasswordStore) load() (Config, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return Load(s.path, s.fallback)
+}
diff --git a/web/backend/main.go b/web/backend/main.go
index c58e97361..fa2448d5c 100644
--- a/web/backend/main.go
+++ b/web/backend/main.go
@@ -12,21 +12,25 @@
package main
import (
+ "context"
"errors"
"flag"
"fmt"
+ "net"
"net/http"
- "net/url"
"os"
"os/signal"
"path/filepath"
"strconv"
+ "strings"
"syscall"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/netbind"
"github.com/sipeed/picoclaw/web/backend/api"
+ "github.com/sipeed/picoclaw/web/backend/dashboardauth"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
"github.com/sipeed/picoclaw/web/backend/middleware"
"github.com/sipeed/picoclaw/web/backend/utils"
@@ -43,40 +47,332 @@ const (
var (
appVersion = config.Version
- server *http.Server
+ servers []*http.Server
serverAddr string
// browserLaunchURL is opened by openBrowser() (auto-open + tray "open console").
- // Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use.
browserLaunchURL string
apiHandler *api.Handler
- // launcherDashboardTokenForClipboard is read by the system tray "copy token" action (GUI mode).
- launcherDashboardTokenForClipboard string
noBrowser *bool
)
+func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool {
+ return !enableConsole || debug
+}
+
+func shouldEnableLocalAutoLogin(noBrowser bool, probeHost string) bool {
+ return !noBrowser && isLoopbackLaunchHost(probeHost)
+}
+
+func isLoopbackLaunchHost(host string) bool {
+ host = strings.TrimSpace(host)
+ if strings.EqualFold(host, "localhost") {
+ return true
+ }
+ host = strings.Trim(host, "[]")
+ if i := strings.LastIndex(host, "%"); i >= 0 {
+ host = host[:i]
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
+}
+
+func launcherBrowserLaunchSuffix(
+ needsSetup bool,
+ localAutoLogin *middleware.LauncherDashboardLocalAutoLogin,
+) string {
+ if needsSetup {
+ return middleware.LauncherDashboardSetupPath
+ }
+ if localAutoLogin != nil {
+ return localAutoLogin.URLPath()
+ }
+ return ""
+}
+
+func resolveLauncherHostInput(flagHost string, explicitFlag bool, envHost string) (string, bool, error) {
+ if explicitFlag {
+ normalized, err := netbind.NormalizeHostInput(flagHost)
+ if err != nil {
+ return "", false, err
+ }
+ return normalized, true, nil
+ }
+
+ envHost = strings.TrimSpace(envHost)
+ if envHost == "" {
+ return "", false, nil
+ }
+
+ normalized, err := netbind.NormalizeHostInput(envHost)
+ if err != nil {
+ return "", false, err
+ }
+ return normalized, true, nil
+}
+
+func openLauncherListeners(hostInput string, public bool, port string) (netbind.OpenResult, error) {
+ defaultMode := netbind.DefaultLoopback
+ if strings.TrimSpace(hostInput) == "" && public {
+ defaultMode = netbind.DefaultAny
+ }
+
+ plan, err := netbind.BuildPlan(hostInput, defaultMode)
+ if err != nil {
+ return netbind.OpenResult{}, err
+ }
+ return netbind.OpenPlan(plan, port)
+}
+
+func appendUniqueHost(hosts []string, seen map[string]struct{}, host string) []string {
+ host = strings.TrimSpace(host)
+ if host == "" {
+ return hosts
+ }
+ key := strings.ToLower(host)
+ if _, ok := seen[key]; ok {
+ return hosts
+ }
+ seen[key] = struct{}{}
+ return append(hosts, host)
+}
+
+func hasWildcardBindHosts(bindHosts []string) bool {
+ for _, bindHost := range bindHosts {
+ if netbind.IsUnspecifiedHost(bindHost) {
+ return true
+ }
+ }
+ return false
+}
+
+func wildcardBindHostFamilies(bindHosts []string) (hasIPv4, hasIPv6 bool) {
+ for _, bindHost := range bindHosts {
+ host := strings.TrimSpace(bindHost)
+ if host == "" {
+ continue
+ }
+
+ if !netbind.IsUnspecifiedHost(host) {
+ continue
+ }
+
+ ip := net.ParseIP(strings.Trim(host, "[]"))
+ if ip == nil {
+ continue
+ }
+ if ip.To4() != nil {
+ hasIPv4 = true
+ continue
+ }
+ hasIPv6 = true
+ }
+
+ return hasIPv4, hasIPv6
+}
+
+func wildcardAdvertiseIP(bindHosts []string, ipv4, ipv6 string) string {
+ hasIPv4Wildcard, hasIPv6Wildcard := wildcardBindHostFamilies(bindHosts)
+ v4 := strings.TrimSpace(ipv4)
+ v6 := strings.TrimSpace(ipv6)
+
+ switch {
+ case hasIPv4Wildcard && hasIPv6Wildcard:
+ if v6 != "" {
+ return v6
+ }
+ return v4
+ case hasIPv6Wildcard:
+ return v6
+ case hasIPv4Wildcard:
+ return v4
+ default:
+ return ""
+ }
+}
+
+func advertiseIPForWildcardBindHosts(bindHosts []string) string {
+ return wildcardAdvertiseIP(bindHosts, utils.GetLocalIPv4(), utils.GetLocalIPv6())
+}
+
+func appendLauncherConsoleHostList(hosts []string, seen map[string]struct{}, values []string) []string {
+ for _, value := range values {
+ hosts = appendUniqueHost(hosts, seen, value)
+ }
+ return hosts
+}
+
+func shouldShowLocalhostConsoleEntry(hostInput string) bool {
+ normalizedHostInput := strings.TrimSpace(hostInput)
+ if normalizedHostInput == "" {
+ return true
+ }
+
+ for token := range strings.SplitSeq(normalizedHostInput, ",") {
+ token = strings.TrimSpace(token)
+ if token == "" {
+ continue
+ }
+ if token == "*" || strings.EqualFold(token, "localhost") {
+ return true
+ }
+
+ ip := net.ParseIP(strings.Trim(token, "[]"))
+ if ip == nil {
+ continue
+ }
+ if ip4 := ip.To4(); ip4 != nil {
+ if ip4.String() == "127.0.0.1" || ip4.String() == "0.0.0.0" {
+ return true
+ }
+ continue
+ }
+ if ip.String() == "::1" || ip.String() == "::" {
+ return true
+ }
+ }
+
+ return false
+}
+
+func isConsoleDisplayGlobalIPv6(ip net.IP) bool {
+ if ip == nil || ip.IsLoopback() || ip.To4() != nil {
+ return false
+ }
+ ip = ip.To16()
+ if ip == nil {
+ return false
+ }
+ return ip[0]&0xe0 == 0x20
+}
+
+func launcherConsoleHostsWithLocalAddrs(
+ hostInput string,
+ public bool,
+ ipv4s []string,
+ globalIPv6s []string,
+) []string {
+ hosts := make([]string, 0, 8)
+ seen := make(map[string]struct{}, 8)
+
+ if shouldShowLocalhostConsoleEntry(hostInput) {
+ hosts = appendUniqueHost(hosts, seen, "localhost")
+ }
+
+ normalizedHostInput := strings.TrimSpace(hostInput)
+ if normalizedHostInput == "" {
+ if public {
+ hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s)
+ hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s)
+ }
+ return hosts
+ }
+
+ hasStar := false
+ hasIPv4Any := false
+ hasIPv6Any := false
+ for _, token := range strings.Split(normalizedHostInput, ",") {
+ switch strings.TrimSpace(token) {
+ case "*":
+ hasStar = true
+ case "0.0.0.0":
+ hasIPv4Any = true
+ case "::":
+ hasIPv6Any = true
+ }
+ }
+
+ if hasStar {
+ hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s)
+ hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s)
+ return hosts
+ }
+
+ for _, token := range strings.Split(normalizedHostInput, ",") {
+ token = strings.TrimSpace(token)
+ if token == "" || strings.EqualFold(token, "localhost") || netbind.IsLoopbackHost(token) {
+ continue
+ }
+
+ ip := net.ParseIP(strings.Trim(token, "[]"))
+ switch {
+ case token == "::":
+ hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s)
+ case token == "0.0.0.0":
+ hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s)
+ case ip != nil && ip.To4() != nil:
+ if hasIPv4Any {
+ continue
+ }
+ hosts = appendUniqueHost(hosts, seen, ip.String())
+ case ip != nil:
+ if hasIPv6Any {
+ continue
+ }
+ if isConsoleDisplayGlobalIPv6(ip) {
+ hosts = appendUniqueHost(hosts, seen, ip.String())
+ }
+ default:
+ hosts = appendUniqueHost(hosts, seen, token)
+ }
+ }
+
+ return hosts
+}
+
+func launcherConsoleHosts(hostInput string, public bool) []string {
+ return launcherConsoleHostsWithLocalAddrs(
+ hostInput,
+ public,
+ utils.GetLocalIPv4s(),
+ utils.GetGlobalIPv6s(),
+ )
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ value = strings.TrimSpace(value)
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
func main() {
port := flag.String("port", "18800", "Port to listen on")
- public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
+ host := flag.String("host", "", "Host to listen on (overrides -public when set)")
+ public := flag.Bool("public", false, "Listen on all interfaces (dual-stack) instead of localhost only")
noBrowser = flag.Bool("no-browser", false, "Do not auto-open browser on startup")
lang := flag.String("lang", "", "Language: en (English) or zh (Chinese). Default: auto-detect from system locale")
console := flag.Bool("console", false, "Console mode, no GUI")
+ var debug bool
+ flag.BoolVar(&debug, "d", false, "Enable debug logging")
+ flag.BoolVar(&debug, "debug", false, "Enable debug logging")
+
flag.Usage = func() {
- fmt.Fprintf(os.Stderr, "%s Launcher - A web-based configuration editor\n\n", appName)
+ fmt.Fprintf(os.Stderr, "%s Launcher - Web console and gateway manager\n\n", appName)
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Arguments:\n")
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
fmt.Fprintf(os.Stderr, "Options:\n")
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nExamples:\n")
- fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0])
- fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0])
+ fmt.Fprintf(os.Stderr, " %s\n", os.Args[0])
+ fmt.Fprintf(os.Stderr, " Use default config path in GUI mode\n")
+ fmt.Fprintf(os.Stderr, " %s ./config.json\n", os.Args[0])
+ fmt.Fprintf(os.Stderr, " Specify a config file\n")
fmt.Fprintf(
os.Stderr,
- " %s -public ./config.json Allow access from other devices on the network\n",
+ " %s -public ./config.json\n",
os.Args[0],
)
+ fmt.Fprintf(os.Stderr, " Allow access from other devices on the local network\n")
+ fmt.Fprintf(os.Stderr, " %s -host :: ./config.json\n", os.Args[0])
+ fmt.Fprintf(os.Stderr, " Bind launcher host explicitly with exact host semantics\n")
+ fmt.Fprintf(os.Stderr, " %s -console -d ./config.json\n", os.Args[0])
+ fmt.Fprintf(os.Stderr, " Run in the terminal with debug logs enabled\n")
}
flag.Parse()
@@ -90,12 +386,13 @@ func main() {
}
defer panicFunc()
- // By default, detect terminal to decide console log behavior
- // If -console-logs flag is explicitly set, it overrides the detection
enableConsole := *console
- if !enableConsole {
- // Disable console logging by setting level to Fatal (no output)
- logger.SetConsoleLevel(logger.FATAL)
+ fileLoggingEnabled := shouldEnableLauncherFileLogging(enableConsole, debug)
+ if fileLoggingEnabled {
+ // GUI mode writes launcher logs to file. Debug mode keeps file logging enabled in console mode too.
+ if !debug {
+ logger.DisableConsole()
+ }
f := filepath.Join(picoHome, logPath, logFile)
if err = logger.EnableFileLogging(f); err != nil {
@@ -103,9 +400,9 @@ func main() {
}
defer logger.DisableFileLogging()
}
-
- logger.InfoC("web", fmt.Sprintf("%s launcher starting (version %s)...", appName, appVersion))
- logger.InfoC("web", fmt.Sprintf("%s Home: %s", appName, picoHome))
+ if debug {
+ logger.SetLevel(logger.DEBUG)
+ }
// Set language from command line or auto-detect
if *lang != "" {
@@ -126,13 +423,36 @@ func main() {
if err != nil {
logger.Errorf("Warning: Failed to initialize %s config automatically: %v", appName, err)
}
+ if !debug {
+ logger.SetLevelFromString(config.ResolveGatewayLogLevel(absPath))
+ }
+
+ logger.InfoC("web", fmt.Sprintf("%s launcher starting (version %s)...", appName, appVersion))
+ logger.InfoC("web", fmt.Sprintf("%s Home: %s", appName, picoHome))
+ if debug {
+ logger.InfoC("web", "Debug mode enabled")
+ logger.DebugC(
+ "web",
+ fmt.Sprintf(
+ "Launcher flags: console=%t host=%q public=%t no_browser=%t config=%s",
+ enableConsole,
+ *host,
+ *public,
+ *noBrowser,
+ absPath,
+ ),
+ )
+ }
var explicitPort bool
var explicitPublic bool
+ var explicitHost bool
flag.Visit(func(f *flag.Flag) {
switch f.Name {
case "port":
explicitPort = true
+ case "host":
+ explicitHost = true
case "public":
explicitPublic = true
}
@@ -153,6 +473,23 @@ func main() {
if !explicitPublic {
effectivePublic = launcherCfg.Public
}
+ envHost := strings.TrimSpace(os.Getenv(launcherconfig.EnvLauncherHost))
+
+ hostInput, hostOverrideActive, err := resolveLauncherHostInput(*host, explicitHost, envHost)
+ if err != nil {
+ logger.Fatalf("Invalid host %q: %v", firstNonEmpty(strings.TrimSpace(*host), envHost), err)
+ }
+ if hostOverrideActive {
+ effectivePublic = false
+ }
+
+ if !explicitHost && hostOverrideActive {
+ logger.InfoC("web", "Using launcher host from environment PICOCLAW_LAUNCHER_HOST")
+ }
+
+ if hostOverrideActive && explicitPublic {
+ logger.InfoC("web", "Ignoring -public because launcher host was explicitly set")
+ }
portNum, err := strconv.Atoi(effectivePort)
if err != nil || portNum < 1 || portNum > 65535 {
@@ -162,45 +499,93 @@ func main() {
logger.Fatalf("Invalid port %q: %v", effectivePort, err)
}
- dashboardToken, dashboardSigningKey, newDashTok, dashErr := launcherconfig.EnsureDashboardSecrets()
+ openResult, err := openLauncherListeners(hostInput, effectivePublic, effectivePort)
+ if err != nil {
+ logger.Fatalf("Failed to open launcher listener(s): %v", err)
+ }
+ listeners := openResult.Listeners
+
+ dashboardSessionCookie, dashErr := middleware.NewLauncherDashboardSessionCookie()
if dashErr != nil {
logger.Fatalf("Dashboard auth setup failed: %v", dashErr)
}
- dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken)
- launcherDashboardTokenForClipboard = dashboardToken
- // Determine listen address
- var addr string
- if effectivePublic {
- addr = "0.0.0.0:" + effectivePort
+ // Open the bcrypt password store (creates the DB file on first run).
+ authStore, authStoreErr := dashboardauth.New(picoHome)
+ var passwordStore api.PasswordStore
+ if authStoreErr == nil {
+ passwordStore = authStore
+ defer authStore.Close()
+ } else if errors.Is(authStoreErr, dashboardauth.ErrUnsupportedPlatform) {
+ logger.InfoC(
+ "web",
+ fmt.Sprintf(
+ "Dashboard SQLite password store unavailable on this platform; using launcher-config password storage: %v",
+ authStoreErr,
+ ),
+ )
+ passwordStore = launcherconfig.NewPasswordStore(launcherPath, launcherCfg)
+ authStoreErr = nil
} else {
- addr = "127.0.0.1:" + effectivePort
+ logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
+ }
+
+ migrationResult, migrationErr := launcherconfig.MigrateLegacyLauncherToken(
+ context.Background(),
+ passwordStore,
+ launcherPath,
+ launcherCfg,
+ )
+ if migrationErr != nil {
+ logger.Fatalf("Failed to migrate legacy launcher token to password login: %v", migrationErr)
+ }
+ if migrationResult.Migrated {
+ logger.InfoC("web", "Migrated legacy launcher token to dashboard password login")
+ }
+ if migrationResult.CleanupErr != nil {
+ logger.WarnC(
+ "web",
+ fmt.Sprintf(
+ "Legacy launcher token password migration succeeded, but failed to remove launcher_token from %s: %v",
+ launcherPath,
+ migrationResult.CleanupErr,
+ ),
+ )
+ }
+
+ var localAutoLogin *middleware.LauncherDashboardLocalAutoLogin
+ needsInitialSetup := false
+ if passwordStore != nil {
+ initialized, initErr := passwordStore.IsInitialized(context.Background())
+ if initErr != nil {
+ logger.ErrorC("web", fmt.Sprintf("Warning: could not check dashboard password state: %v", initErr))
+ } else if !initialized {
+ needsInitialSetup = true
+ } else if shouldEnableLocalAutoLogin(*noBrowser, openResult.ProbeHost) {
+ localAutoLogin, err = middleware.NewLauncherDashboardLocalAutoLogin(5 * time.Minute)
+ if err != nil {
+ logger.Fatalf("Failed to create local auto-login grant: %v", err)
+ }
+ }
}
// Initialize Server components
mux := http.NewServeMux()
- tokenLogFileAbs := ""
- if !enableConsole {
- tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile)
- }
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
- DashboardToken: dashboardToken,
- SessionCookie: dashboardSessionCookie,
- TokenHelp: api.LauncherAuthTokenHelp{
- EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
- LogFileAbs: tokenLogFileAbs,
- TrayCopyMenu: trayOffersDashboardTokenCopy(),
- ConsoleStdout: enableConsole,
- },
+ SessionCookie: dashboardSessionCookie,
+ PasswordStore: passwordStore,
+ StoreError: authStoreErr,
})
// API Routes (e.g. /api/status)
apiHandler = api.NewHandler(absPath)
- if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
+ apiHandler.SetDebug(debug)
+ if _, err = apiHandler.EnsurePicoChannel(); err != nil {
logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err))
}
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
+ apiHandler.SetServerBindHost(hostInput, hostOverrideActive)
apiHandler.RegisterRoutes(mux)
// Frontend Embedded Assets
@@ -213,7 +598,7 @@ func main() {
dashAuth := middleware.LauncherDashboardAuth(middleware.LauncherDashboardAuthConfig{
ExpectedCookie: dashboardSessionCookie,
- Token: dashboardToken,
+ LocalAutoLogin: localAutoLogin,
}, accessControlledMux)
// Apply middleware stack
@@ -225,49 +610,41 @@ func main() {
),
)
- // Print startup banner and token (console mode only).
- if enableConsole {
+ // Print startup banner (console mode only).
+ if enableConsole || debug {
+ consoleHosts := launcherConsoleHosts(hostInput, effectivePublic)
+
fmt.Print(utils.Banner)
fmt.Println()
- fmt.Println(" Open the following URL in your browser:")
- fmt.Println()
- fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
- if effectivePublic {
- if ip := utils.GetLocalIP(); ip != "" {
- fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort)
+ if needsInitialSetup {
+ if *noBrowser {
+ fmt.Println(" First-time setup: open /launcher-setup to create the dashboard password.")
+ } else {
+ fmt.Println(" Launcher will open /launcher-setup automatically.")
}
+ fmt.Println()
+ }
+ fmt.Println(" Dashboard address:")
+ fmt.Println()
+ for _, host := range consoleHosts {
+ fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort))
}
fmt.Println()
- if newDashTok {
- fmt.Printf(" Dashboard token (this run): %s\n", dashboardToken)
- } else if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" {
- fmt.Printf(" Dashboard token: %s (from PICOCLAW_LAUNCHER_TOKEN)\n", dashboardToken)
- }
- fmt.Println()
- }
-
- if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" {
- logger.InfoC("web", "Dashboard token: environment PICOCLAW_LAUNCHER_TOKEN")
- }
- if !enableConsole && newDashTok {
- logger.InfoC("web", "Dashboard token (this run): "+dashboardToken)
}
// Log startup info to file
- logger.InfoC("web", fmt.Sprintf("Server will listen on http://localhost:%s", effectivePort))
- if effectivePublic {
- if ip := utils.GetLocalIP(); ip != "" {
- logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s:%s", ip, effectivePort))
+ for _, ln := range listeners {
+ logger.InfoC("web", fmt.Sprintf("Server will listen on http://%s", ln.Addr().String()))
+ }
+ if hasWildcardBindHosts(openResult.BindHosts) {
+ if ip := advertiseIPForWildcardBindHosts(openResult.BindHosts); ip != "" {
+ logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s", net.JoinHostPort(ip, effectivePort)))
}
}
// Share the local URL with the launcher runtime.
- serverAddr = fmt.Sprintf("http://localhost:%s", effectivePort)
- if dashboardToken != "" {
- browserLaunchURL = serverAddr + "?token=" + url.QueryEscape(dashboardToken)
- } else {
- browserLaunchURL = serverAddr
- }
+ serverAddr = fmt.Sprintf("http://%s", net.JoinHostPort(openResult.ProbeHost, effectivePort))
+ browserLaunchURL = serverAddr + launcherBrowserLaunchSuffix(needsInitialSetup, localAutoLogin)
// Auto-open browser will be handled by the launcher runtime.
@@ -277,14 +654,19 @@ func main() {
apiHandler.TryAutoStartGateway()
}()
- // Start the Server in a goroutine
- server = &http.Server{Addr: addr, Handler: handler}
- go func() {
- logger.InfoC("web", fmt.Sprintf("Server listening on %s", addr))
- if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- logger.Fatalf("Server failed to start: %v", err)
- }
- }()
+ // Start the server(s) in goroutines.
+ servers = make([]*http.Server, 0, len(listeners))
+ for _, ln := range listeners {
+ srv := &http.Server{Handler: handler}
+ servers = append(servers, srv)
+
+ go func(s *http.Server, l net.Listener) {
+ logger.InfoC("web", fmt.Sprintf("Server listening on %s", l.Addr().String()))
+ if serveErr := s.Serve(l); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
+ logger.Fatalf("Server failed to start on %s: %v", l.Addr().String(), serveErr)
+ }
+ }(srv, ln)
+ }
defer shutdownApp()
diff --git a/web/backend/main_test.go b/web/backend/main_test.go
new file mode 100644
index 000000000..aea02927e
--- /dev/null
+++ b/web/backend/main_test.go
@@ -0,0 +1,422 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/netbind"
+ "github.com/sipeed/picoclaw/web/backend/middleware"
+)
+
+func TestShouldEnableLauncherFileLogging(t *testing.T) {
+ tests := []struct {
+ name string
+ enableConsole bool
+ debug bool
+ want bool
+ }{
+ {name: "gui mode", enableConsole: false, debug: false, want: true},
+ {name: "console mode", enableConsole: true, debug: false, want: false},
+ {name: "debug gui mode", enableConsole: false, debug: true, want: true},
+ {name: "debug console mode", enableConsole: true, debug: true, want: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := shouldEnableLauncherFileLogging(tt.enableConsole, tt.debug); got != tt.want {
+ t.Fatalf(
+ "shouldEnableLauncherFileLogging(%t, %t) = %t, want %t",
+ tt.enableConsole,
+ tt.debug,
+ got,
+ tt.want,
+ )
+ }
+ })
+ }
+}
+
+func TestShouldEnableLocalAutoLogin(t *testing.T) {
+ tests := []struct {
+ name string
+ noBrowser bool
+ probeHost string
+ wantEnable bool
+ }{
+ {name: "loopback localhost", probeHost: "localhost", wantEnable: true},
+ {name: "loopback ipv4", probeHost: "127.0.0.1", wantEnable: true},
+ {name: "loopback ipv6", probeHost: "::1", wantEnable: true},
+ {name: "browser disabled", noBrowser: true, probeHost: "localhost", wantEnable: false},
+ {name: "non-loopback host", probeHost: "192.168.1.50", wantEnable: false},
+ {name: "non-loopback hostname", probeHost: "example.com", wantEnable: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := shouldEnableLocalAutoLogin(tt.noBrowser, tt.probeHost); got != tt.wantEnable {
+ t.Fatalf(
+ "shouldEnableLocalAutoLogin(%t, %q) = %t, want %t",
+ tt.noBrowser,
+ tt.probeHost,
+ got,
+ tt.wantEnable,
+ )
+ }
+ })
+ }
+}
+
+func TestLauncherBrowserLaunchSuffix(t *testing.T) {
+ autoLogin, err := middleware.NewLauncherDashboardLocalAutoLogin(time.Minute)
+ if err != nil {
+ t.Fatalf("NewLauncherDashboardLocalAutoLogin() error = %v", err)
+ }
+
+ if got := launcherBrowserLaunchSuffix(true, autoLogin); got != middleware.LauncherDashboardSetupPath {
+ t.Fatalf("setup suffix = %q", got)
+ }
+ if got := launcherBrowserLaunchSuffix(false, autoLogin); !strings.HasPrefix(got, "/launcher-auto-login?nonce=") {
+ t.Fatalf("auto-login suffix = %q", got)
+ }
+ if got := launcherBrowserLaunchSuffix(false, nil); got != "" {
+ t.Fatalf("empty suffix = %q, want empty", got)
+ }
+}
+
+func TestResolveLauncherHostInput(t *testing.T) {
+ tests := []struct {
+ name string
+ flagHost string
+ explicitFlag bool
+ envHost string
+ wantHost string
+ wantActive bool
+ wantErr bool
+ }{
+ {
+ name: "flag host wins",
+ flagHost: "127.0.0.1",
+ explicitFlag: true,
+ envHost: "::",
+ wantHost: "127.0.0.1",
+ wantActive: true,
+ },
+ {name: "env host used when flag absent", envHost: "127.0.0.1,::1", wantHost: "127.0.0.1,::1", wantActive: true},
+ {name: "blank env ignored", envHost: " ", wantHost: "", wantActive: false},
+ {name: "invalid flag rejected", flagHost: "127.0.0.1, ", explicitFlag: true, wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotHost, gotActive, err := resolveLauncherHostInput(tt.flagHost, tt.explicitFlag, tt.envHost)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("resolveLauncherHostInput() err = %v, wantErr %t", err, tt.wantErr)
+ }
+ if tt.wantErr {
+ return
+ }
+ if gotHost != tt.wantHost {
+ t.Fatalf("resolveLauncherHostInput() host = %q, want %q", gotHost, tt.wantHost)
+ }
+ if gotActive != tt.wantActive {
+ t.Fatalf("resolveLauncherHostInput() active = %t, want %t", gotActive, tt.wantActive)
+ }
+ })
+ }
+}
+
+func TestLauncherConsoleHosts(t *testing.T) {
+ t.Run("default loopback shows localhost only", func(t *testing.T) {
+ hosts := launcherConsoleHostsWithLocalAddrs(
+ "",
+ false,
+ []string{"192.168.1.2", "10.0.0.8"},
+ []string{"2001:db8::1", "2001:db8::2"},
+ )
+ want := []string{"localhost"}
+ if strings.Join(hosts, ",") != strings.Join(want, ",") {
+ t.Fatalf("hosts = %#v, want %#v", hosts, want)
+ }
+ })
+
+ t.Run("explicit loopback hosts collapse to localhost", func(t *testing.T) {
+ tests := []struct {
+ name string
+ hostInput string
+ }{
+ {name: "ipv6 loopback", hostInput: "::1"},
+ {name: "ipv4 loopback", hostInput: "127.0.0.1"},
+ {name: "localhost", hostInput: "localhost"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ hosts := launcherConsoleHostsWithLocalAddrs(
+ tt.hostInput,
+ false,
+ []string{"192.168.1.2", "10.0.0.8"},
+ []string{"2001:db8::1", "2001:db8::2"},
+ )
+ want := []string{"localhost"}
+ if strings.Join(hosts, ",") != strings.Join(want, ",") {
+ t.Fatalf("hosts = %#v, want %#v", hosts, want)
+ }
+ })
+ }
+ })
+
+ t.Run("public wildcard shows localhost then ipv6 and ipv4", func(t *testing.T) {
+ hosts := launcherConsoleHostsWithLocalAddrs(
+ "",
+ true,
+ []string{"192.168.1.2", "10.0.0.8"},
+ []string{"2001:db8::1", "2001:db8::2"},
+ )
+ want := []string{"localhost", "2001:db8::1", "2001:db8::2", "192.168.1.2", "10.0.0.8"}
+ if strings.Join(hosts, ",") != strings.Join(want, ",") {
+ t.Fatalf("hosts = %#v, want %#v", hosts, want)
+ }
+ })
+
+ t.Run("explicit ipv6 any shows localhost then ipv6 variants", func(t *testing.T) {
+ hosts := launcherConsoleHostsWithLocalAddrs(
+ "::",
+ false,
+ []string{"192.168.1.2", "10.0.0.8"},
+ []string{"2001:db8::1", "2001:db8::2"},
+ )
+ want := []string{"localhost", "2001:db8::1", "2001:db8::2"}
+ if strings.Join(hosts, ",") != strings.Join(want, ",") {
+ t.Fatalf("hosts = %#v, want %#v", hosts, want)
+ }
+
+ for _, host := range hosts {
+ if host == "::1" || host == "127.0.0.1" || strings.HasPrefix(strings.ToLower(host), "fe80:") {
+ t.Fatalf("hosts = %#v, loopback IPs must not be displayed", hosts)
+ }
+ }
+ })
+
+ t.Run("explicit ipv4 any shows localhost then lan ipv4", func(t *testing.T) {
+ hosts := launcherConsoleHostsWithLocalAddrs(
+ "0.0.0.0",
+ false,
+ []string{"192.168.1.2", "10.0.0.8"},
+ []string{"2001:db8::1", "2001:db8::2"},
+ )
+ want := []string{"localhost", "192.168.1.2", "10.0.0.8"}
+ if strings.Join(hosts, ",") != strings.Join(want, ",") {
+ t.Fatalf("hosts = %#v, want %#v", hosts, want)
+ }
+ })
+
+ t.Run("explicit wildcard star shows localhost first", func(t *testing.T) {
+ hosts := launcherConsoleHostsWithLocalAddrs(
+ "*",
+ false,
+ []string{"192.168.1.2", "10.0.0.8"},
+ []string{"2001:db8::1", "2001:db8::2"},
+ )
+ want := []string{"localhost", "2001:db8::1", "2001:db8::2", "192.168.1.2", "10.0.0.8"}
+ if strings.Join(hosts, ",") != strings.Join(want, ",") {
+ t.Fatalf("hosts = %#v, want %#v", hosts, want)
+ }
+ })
+
+ t.Run("explicit multi-address binding without local tokens hides localhost", func(t *testing.T) {
+ hosts := launcherConsoleHostsWithLocalAddrs(
+ "192.168.1.2,10.0.0.8,2001:db8::1,2001:db8::2,fe80::1",
+ false,
+ []string{"192.168.1.2", "10.0.0.8"},
+ []string{"2001:db8::1", "2001:db8::2"},
+ )
+ want := []string{"192.168.1.2", "10.0.0.8", "2001:db8::1", "2001:db8::2"}
+ if strings.Join(hosts, ",") != strings.Join(want, ",") {
+ t.Fatalf("hosts = %#v, want %#v", hosts, want)
+ }
+ })
+}
+
+func TestWildcardAdvertiseIP(t *testing.T) {
+ tests := []struct {
+ name string
+ bindHosts []string
+ ipv4 string
+ ipv6 string
+ want string
+ }{
+ {
+ name: "ipv4 wildcard uses ipv4",
+ bindHosts: []string{"0.0.0.0"},
+ ipv4: "192.168.1.2",
+ ipv6: "2001:db8::1",
+ want: "192.168.1.2",
+ },
+ {
+ name: "dual wildcard prefers ipv6",
+ bindHosts: []string{"0.0.0.0", "::"},
+ ipv4: "192.168.1.2",
+ ipv6: "2001:db8::1",
+ want: "2001:db8::1",
+ },
+ {
+ name: "ipv6 wildcard uses ipv6",
+ bindHosts: []string{"::"},
+ ipv4: "192.168.1.2",
+ ipv6: "2001:db8::1",
+ want: "2001:db8::1",
+ },
+ {
+ name: "dual wildcard falls back to ipv4 when ipv6 missing",
+ bindHosts: []string{"0.0.0.0", "::"},
+ ipv4: "192.168.1.2",
+ ipv6: "",
+ want: "192.168.1.2",
+ },
+ {
+ name: "ipv6 wildcard without ipv6 does not advertise ipv4",
+ bindHosts: []string{"::"},
+ ipv4: "192.168.1.2",
+ ipv6: "",
+ want: "",
+ },
+ {
+ name: "non wildcard does not advertise",
+ bindHosts: []string{"127.0.0.1"},
+ ipv4: "192.168.1.2",
+ ipv6: "2001:db8::1",
+ want: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := wildcardAdvertiseIP(tt.bindHosts, tt.ipv4, tt.ipv6); got != tt.want {
+ t.Fatalf("wildcardAdvertiseIP(%#v, %q, %q) = %q, want %q", tt.bindHosts, tt.ipv4, tt.ipv6, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestOpenLauncherListeners_HonorsIPv6OnlyHost(t *testing.T) {
+ hasIPv4, hasIPv6 := netbind.DetectIPFamilies()
+ if !hasIPv6 {
+ t.Skip("IPv6 is unavailable in this environment")
+ }
+
+ result, err := openLauncherListeners("::", false, "0")
+ if err != nil {
+ t.Fatalf("openLauncherListeners() error = %v", err)
+ }
+ startLauncherTestHTTPServer(t, result.Listeners)
+ port := mustAtoi(t, result.Port)
+
+ requireLauncherHTTPReachable(t, "::1", port)
+ if hasIPv4 {
+ requireLauncherHTTPUnreachable(t, "127.0.0.1", port)
+ }
+}
+
+func TestOpenLauncherListeners_SupportsExplicitMultiHost(t *testing.T) {
+ hasIPv4, hasIPv6 := netbind.DetectIPFamilies()
+ if !hasIPv4 || !hasIPv6 {
+ t.Skip("dual-stack loopback is unavailable in this environment")
+ }
+
+ result, err := openLauncherListeners("127.0.0.1,::1", false, "0")
+ if err != nil {
+ t.Fatalf("openLauncherListeners() error = %v", err)
+ }
+ startLauncherTestHTTPServer(t, result.Listeners)
+ port := mustAtoi(t, result.Port)
+
+ requireLauncherHTTPReachable(t, "127.0.0.1", port)
+ requireLauncherHTTPReachable(t, "::1", port)
+}
+
+func startLauncherTestHTTPServer(t *testing.T, listeners []net.Listener) {
+ t.Helper()
+
+ server := &http.Server{
+ Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, "ok")
+ }),
+ }
+
+ errCh := make(chan error, len(listeners))
+ for _, listener := range listeners {
+ ln := listener
+ go func() {
+ errCh <- server.Serve(ln)
+ }()
+ }
+
+ t.Cleanup(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ _ = server.Shutdown(ctx)
+ for range listeners {
+ err := <-errCh
+ if err != nil && !errors.Is(err, http.ErrServerClosed) {
+ t.Fatalf("server.Serve() error = %v", err)
+ }
+ }
+ })
+}
+
+func requireLauncherHTTPReachable(t *testing.T, host string, port int) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for {
+ err := launcherHTTPGet(host, port)
+ if err == nil {
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("expected %s:%d to be reachable: %v", host, port, err)
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+}
+
+func requireLauncherHTTPUnreachable(t *testing.T, host string, port int) {
+ t.Helper()
+ if err := launcherHTTPGet(host, port); err == nil {
+ t.Fatalf("expected %s:%d to be unreachable", host, port)
+ }
+}
+
+func launcherHTTPGet(host string, port int) error {
+ client := &http.Client{
+ Timeout: 300 * time.Millisecond,
+ Transport: &http.Transport{
+ Proxy: nil,
+ },
+ }
+
+ resp, err := client.Get("http://" + net.JoinHostPort(host, strconv.Itoa(port)))
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return errors.New(resp.Status)
+ }
+ return nil
+}
+
+func mustAtoi(t *testing.T, value string) int {
+ t.Helper()
+ n, err := strconv.Atoi(value)
+ if err != nil {
+ t.Fatalf("Atoi(%q) error = %v", value, err)
+ }
+ return n
+}
diff --git a/web/backend/middleware/launcher_dashboard_auth.go b/web/backend/middleware/launcher_dashboard_auth.go
index 7e92fca22..fd59958a9 100644
--- a/web/backend/middleware/launcher_dashboard_auth.go
+++ b/web/backend/middleware/launcher_dashboard_auth.go
@@ -1,41 +1,88 @@
package middleware
import (
- "crypto/hmac"
- "crypto/sha256"
+ "crypto/rand"
"crypto/subtle"
- "encoding/hex"
+ "encoding/base64"
+ "errors"
"net/http"
+ "net/url"
"path"
"strings"
+ "sync"
"time"
)
-// LauncherDashboardCookieName is the HttpOnly cookie set after a successful token login.
+// LauncherDashboardCookieName is the HttpOnly cookie set after a successful password login.
const LauncherDashboardCookieName = "picoclaw_launcher_auth"
-// launcherDashboardSessionMaxAgeSec is the session cookie lifetime (7 days).
-const launcherDashboardSessionMaxAgeSec = 7 * 24 * 3600
+// launcherDashboardSessionMaxAgeSec is the dashboard session cookie lifetime (31 days).
+const launcherDashboardSessionMaxAgeSec = 31 * 24 * 3600
-const launcherSessionMACLabel = "picoclaw-launcher-v1"
+const (
+ launcherSessionCookieBytes = 32
+ launcherGrantNonceBytes = 32
+ // LauncherDashboardLocalAutoLoginPath is the one-shot local browser
+ // bootstrap endpoint used by the launcher-managed auto-open flow.
+ LauncherDashboardLocalAutoLoginPath = "/launcher-auto-login"
+ // LauncherDashboardSetupPath is the setup page used before the dashboard
+ // password is initialized.
+ LauncherDashboardSetupPath = "/launcher-setup"
+)
-// SessionCookieValue is the expected cookie value for the given signing key and dashboard token.
-func SessionCookieValue(signingKey []byte, dashboardToken string) string {
- mac := hmac.New(sha256.New, signingKey)
- _, _ = mac.Write([]byte(launcherSessionMACLabel))
- _, _ = mac.Write([]byte{0})
- _, _ = mac.Write([]byte(dashboardToken))
- return hex.EncodeToString(mac.Sum(nil))
+// NewLauncherDashboardSessionCookie creates the per-process session cookie value.
+func NewLauncherDashboardSessionCookie() (string, error) {
+ return randomURLToken(launcherSessionCookieBytes)
+}
+
+func randomURLToken(n int) (string, error) {
+ buf := make([]byte, n)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(buf), nil
}
// LauncherDashboardAuthConfig holds runtime material for dashboard access checks.
type LauncherDashboardAuthConfig struct {
ExpectedCookie string
- Token string
+ // LocalAutoLogin enables one-shot startup auto-login.
+ LocalAutoLogin *LauncherDashboardLocalAutoLogin
// SecureCookie sets the session cookie's Secure flag. If nil, DefaultLauncherDashboardSecureCookie is used.
SecureCookie func(*http.Request) bool
}
+// LauncherDashboardLocalAutoLogin is an in-memory, one-shot startup grant.
+// It is not a reusable credential; it only lets the launcher-opened browser
+// receive the current process session cookie.
+type LauncherDashboardLocalAutoLogin struct {
+ grant *launcherDashboardOneTimeGrant
+}
+
+type launcherDashboardOneTimeGrant struct {
+ mu sync.Mutex
+ expires time.Time
+ consumed bool
+ nonce string
+ now func() time.Time
+}
+
+// NewLauncherDashboardLocalAutoLogin creates a one-shot local auto-login grant.
+func NewLauncherDashboardLocalAutoLogin(ttl time.Duration) (*LauncherDashboardLocalAutoLogin, error) {
+ grant, err := newLauncherDashboardOneTimeGrant(ttl)
+ if err != nil {
+ return nil, err
+ }
+ return &LauncherDashboardLocalAutoLogin{
+ grant: grant,
+ }, nil
+}
+
+// URLPath returns the one-shot local auto-login URL path including its nonce.
+func (a *LauncherDashboardLocalAutoLogin) URLPath() string {
+ return launcherGrantQueryPath(LauncherDashboardLocalAutoLoginPath, a.grant)
+}
+
// DefaultLauncherDashboardSecureCookie mirrors typical production HTTPS detection (TLS or X-Forwarded-Proto).
func DefaultLauncherDashboardSecureCookie(r *http.Request) bool {
if r.TLS != nil {
@@ -44,7 +91,7 @@ func DefaultLauncherDashboardSecureCookie(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
-// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard token login.
+// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard password login.
func SetLauncherDashboardSessionCookie(
w http.ResponseWriter,
r *http.Request,
@@ -82,12 +129,13 @@ func ClearLauncherDashboardSessionCookie(w http.ResponseWriter, r *http.Request,
})
}
-// LauncherDashboardAuth requires a valid session cookie or Authorization: Bearer
-// before calling next. Public paths are login page and /api/auth/* handlers.
+// LauncherDashboardAuth requires a valid session cookie before calling next.
+// Public paths are login/setup pages and /api/auth/* handlers.
func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := canonicalAuthPath(r.URL.Path)
- if handled := tryLauncherQueryTokenLogin(w, r, p, cfg); handled {
+ if p == LauncherDashboardLocalAutoLoginPath {
+ handleLauncherLocalAutoLogin(w, r, cfg)
return
}
if isPublicLauncherDashboardPath(r.Method, p) {
@@ -105,45 +153,84 @@ func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) h
// canonicalAuthPath matches path cleaning used for routing decisions so
// prefixes like /assets/../ cannot bypass auth (CVE-class traversal).
-// tryLauncherQueryTokenLogin validates ?token= on GET only (non-/api), sets the session
-// cookie when correct, and redirects with 303 so the follow-up is a plain GET without side effects.
-// Invalid token is rejected like any other unauthenticated browser request.
-func tryLauncherQueryTokenLogin(
- w http.ResponseWriter,
- r *http.Request,
- canonicalPath string,
- cfg LauncherDashboardAuthConfig,
-) bool {
- if r.Method != http.MethodGet {
- return false
+func handleLauncherLocalAutoLogin(w http.ResponseWriter, r *http.Request, cfg LauncherDashboardAuthConfig) {
+ if validLauncherDashboardAuth(r, cfg) {
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
}
- if canonicalPath == "/api" || strings.HasPrefix(canonicalPath, "/api/") {
- return false
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ _, _ = w.Write([]byte("method not allowed"))
+ return
}
- qToken := strings.TrimSpace(r.URL.Query().Get("token"))
- if qToken == "" {
- return false
+ if r.Method == http.MethodHead {
+ rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath)
+ return
}
- if len(qToken) != len(cfg.Token) || subtle.ConstantTimeCompare([]byte(qToken), []byte(cfg.Token)) != 1 {
- rejectLauncherDashboardAuth(w, r, canonicalPath)
- return true
+ if cfg.LocalAutoLogin != nil && cfg.LocalAutoLogin.consume(r.URL.Query().Get("nonce")) {
+ SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie)
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
}
- SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie)
- http.Redirect(w, r, redirectAfterQueryTokenLogin(r, canonicalPath), http.StatusSeeOther)
- return true
+ rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath)
}
-func redirectAfterQueryTokenLogin(r *http.Request, canonicalPath string) string {
- if canonicalPath == "/launcher-login" {
- return "/"
+func (a *LauncherDashboardLocalAutoLogin) consume(nonce string) bool {
+ if a == nil || a.grant == nil {
+ return false
}
- q := r.URL.Query()
- q.Del("token")
- enc := q.Encode()
- if enc != "" {
- return canonicalPath + "?" + enc
+ return a.grant.use(nonce, nil) == nil
+}
+
+func newLauncherDashboardOneTimeGrant(ttl time.Duration) (*launcherDashboardOneTimeGrant, error) {
+ nonce, err := randomURLToken(launcherGrantNonceBytes)
+ if err != nil {
+ return nil, err
}
- return canonicalPath
+ return &launcherDashboardOneTimeGrant{
+ expires: time.Now().Add(ttl),
+ nonce: nonce,
+ now: time.Now,
+ }, nil
+}
+
+func launcherGrantQueryPath(basePath string, grant *launcherDashboardOneTimeGrant) string {
+ if grant == nil {
+ return basePath
+ }
+ return basePath + "?nonce=" + url.QueryEscape(grant.nonce)
+}
+
+// ErrInvalidLauncherDashboardGrant reports that an auto-login grant is missing,
+// expired, already consumed, or otherwise invalid.
+var ErrInvalidLauncherDashboardGrant = errors.New("invalid launcher dashboard grant")
+
+func (g *launcherDashboardOneTimeGrant) use(nonce string, fn func() error) error {
+ if g == nil {
+ return ErrInvalidLauncherDashboardGrant
+ }
+ if len(nonce) != len(g.nonce) ||
+ subtle.ConstantTimeCompare([]byte(nonce), []byte(g.nonce)) != 1 {
+ return ErrInvalidLauncherDashboardGrant
+ }
+
+ g.mu.Lock()
+ defer g.mu.Unlock()
+
+ now := time.Now
+ if g.now != nil {
+ now = g.now
+ }
+ if g.consumed || !now().Before(g.expires) {
+ return ErrInvalidLauncherDashboardGrant
+ }
+ if fn != nil {
+ if err := fn(); err != nil {
+ return err
+ }
+ }
+ g.consumed = true
+ return nil
}
func canonicalAuthPath(raw string) string {
@@ -173,6 +260,8 @@ func isPublicLauncherDashboardPath(method, p string) bool {
return method == http.MethodPost
case "/api/auth/status":
return method == http.MethodGet
+ case "/api/auth/setup":
+ return method == http.MethodPost
}
return false
}
@@ -183,7 +272,7 @@ func isPublicLauncherDashboardStatic(method, p string) bool {
if method != http.MethodGet && method != http.MethodHead {
return false
}
- if p == "/launcher-login" {
+ if p == "/launcher-login" || p == "/launcher-setup" {
return true
}
if strings.HasPrefix(p, "/assets/") {
@@ -204,18 +293,14 @@ func validLauncherDashboardAuth(r *http.Request, cfg LauncherDashboardAuthConfig
return true
}
}
- auth := r.Header.Get("Authorization")
- const prefix = "Bearer "
- if strings.HasPrefix(auth, prefix) {
- token := strings.TrimSpace(auth[len(prefix):])
- if len(token) == len(cfg.Token) && subtle.ConstantTimeCompare([]byte(token), []byte(cfg.Token)) == 1 {
- return true
- }
- }
return false
}
func rejectLauncherDashboardAuth(w http.ResponseWriter, r *http.Request, canonicalPath string) {
+ if canonicalPath == "/pico/ws" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
if strings.HasPrefix(canonicalPath, "/api/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
diff --git a/web/backend/middleware/launcher_dashboard_auth_test.go b/web/backend/middleware/launcher_dashboard_auth_test.go
index 1b919bf96..871b6f607 100644
--- a/web/backend/middleware/launcher_dashboard_auth_test.go
+++ b/web/backend/middleware/launcher_dashboard_auth_test.go
@@ -4,26 +4,37 @@ import (
"net/http"
"net/http/httptest"
"testing"
+ "time"
)
-func TestSessionCookieValue_Deterministic(t *testing.T) {
- key := make([]byte, 32)
- for i := range key {
- key[i] = byte(i)
+func TestNewLauncherDashboardSessionCookie(t *testing.T) {
+ a, err := NewLauncherDashboardSessionCookie()
+ if err != nil {
+ t.Fatalf("NewLauncherDashboardSessionCookie() error = %v", err)
}
- a := SessionCookieValue(key, "tok-a")
- b := SessionCookieValue(key, "tok-a")
- if a != b || a == "" {
- t.Fatalf("SessionCookieValue mismatch or empty: %q vs %q", a, b)
+ b, err := NewLauncherDashboardSessionCookie()
+ if err != nil {
+ t.Fatalf("NewLauncherDashboardSessionCookie() second error = %v", err)
}
- c := SessionCookieValue(key, "tok-b")
- if c == a {
- t.Fatal("SessionCookieValue should differ for different tokens")
+ if a == "" || b == "" {
+ t.Fatalf("session cookie values should be non-empty: %q %q", a, b)
+ }
+ if a == b {
+ t.Fatal("session cookie values should be random")
}
}
+func mustLocalAutoLogin(t *testing.T, ttl time.Duration) *LauncherDashboardLocalAutoLogin {
+ t.Helper()
+ autoLogin, err := NewLauncherDashboardLocalAutoLogin(ttl)
+ if err != nil {
+ t.Fatalf("NewLauncherDashboardLocalAutoLogin() error = %v", err)
+ }
+ return autoLogin
+}
+
func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) {
- cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"}
+ cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
})
@@ -34,12 +45,15 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) {
want int
}{
{http.MethodGet, "/launcher-login", http.StatusTeapot},
+ {http.MethodGet, "/launcher-setup", http.StatusTeapot},
{http.MethodGet, "/assets/index.js", http.StatusTeapot},
{http.MethodPost, "/api/auth/login", http.StatusTeapot},
{http.MethodGet, "/api/auth/status", http.StatusTeapot},
+ {http.MethodPost, "/api/auth/setup", http.StatusTeapot},
{http.MethodPost, "/api/auth/logout", http.StatusTeapot},
{http.MethodGet, "/api/auth/logout", http.StatusUnauthorized},
{http.MethodGet, "/api/config", http.StatusUnauthorized},
+ {http.MethodGet, "/pico/ws", http.StatusUnauthorized},
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(tc.method, tc.path, nil)
@@ -50,68 +64,143 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) {
}
}
-func TestLauncherDashboardAuth_URLTokenBootstrapGET(t *testing.T) {
- const tok = "secret"
- cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: tok}
+func TestLauncherDashboardAuth_QueryTokenDoesNotAuthenticate(t *testing.T) {
+ cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusTeapot)
+ t.Fatal("next handler should not run without session cookie")
})
h := LauncherDashboardAuth(cfg, next)
rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/?token="+tok, nil)
+ req := httptest.NewRequest(http.MethodGet, "/?token=secret", nil)
h.ServeHTTP(rec, req)
- if rec.Code != http.StatusSeeOther {
- t.Fatalf("GET /?token=valid: status = %d, want %d", rec.Code, http.StatusSeeOther)
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" {
+ t.Fatalf("GET /?token=secret: code=%d loc=%q", rec.Code, rec.Header().Get("Location"))
}
- if got := rec.Header().Get("Location"); got != "/" {
- t.Fatalf("Location = %q, want %q", got, "/")
+}
+
+func TestLauncherDashboardAuth_LocalAutoLogin(t *testing.T) {
+ const cookieVal = "session-cookie-value"
+ autoLogin := mustLocalAutoLogin(t, time.Minute)
+ cfg := LauncherDashboardAuthConfig{
+ ExpectedCookie: cookieVal,
+ LocalAutoLogin: autoLogin,
}
- if c := rec.Result().Cookies(); len(c) != 1 || c[0].Name != LauncherDashboardCookieName {
- t.Fatalf("expected one session cookie, got %#v", c)
+ next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+ h := LauncherDashboardAuth(cfg, next)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, LauncherDashboardLocalAutoLoginPath, nil)
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" ||
+ len(rec.Result().Cookies()) != 0 {
+ t.Fatalf(
+ "auto-login without nonce code=%d loc=%q cookies=%#v",
+ rec.Code,
+ rec.Header().Get("Location"),
+ rec.Result().Cookies(),
+ )
}
- rec1b := httptest.NewRecorder()
- req1b := httptest.NewRequest(http.MethodGet, "/config?token="+tok+"&keep=1", nil)
- h.ServeHTTP(rec1b, req1b)
- if rec1b.Code != http.StatusSeeOther {
- t.Fatalf("GET /config?token=valid: status = %d", rec1b.Code)
- }
- if got := rec1b.Header().Get("Location"); got != "/config?keep=1" {
- t.Fatalf("Location = %q, want /config?keep=1", got)
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, LauncherDashboardLocalAutoLoginPath+"?nonce=wrong", nil)
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" ||
+ len(rec.Result().Cookies()) != 0 {
+ t.Fatalf(
+ "auto-login with wrong nonce code=%d loc=%q cookies=%#v",
+ rec.Code,
+ rec.Header().Get("Location"),
+ rec.Result().Cookies(),
+ )
}
- recBad := httptest.NewRecorder()
- reqBad := httptest.NewRequest(http.MethodGet, "/?token=wrong", nil)
- h.ServeHTTP(recBad, reqBad)
- if recBad.Code != http.StatusFound || recBad.Header().Get("Location") != "/launcher-login" {
- t.Fatalf("GET /?token=invalid: code=%d loc=%q", recBad.Code, recBad.Header().Get("Location"))
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodHead, autoLogin.URLPath(), nil)
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" ||
+ len(rec.Result().Cookies()) != 0 {
+ t.Fatalf(
+ "auto-login HEAD code=%d loc=%q cookies=%#v",
+ rec.Code,
+ rec.Header().Get("Location"),
+ rec.Result().Cookies(),
+ )
}
- rec2 := httptest.NewRecorder()
- req2 := httptest.NewRequest(http.MethodGet, "/api/config?token="+tok, nil)
- h.ServeHTTP(rec2, req2)
- if rec2.Code != http.StatusUnauthorized {
- t.Fatalf("GET /api with token query: status = %d, want %d", rec2.Code, http.StatusUnauthorized)
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
+ t.Fatalf("local auto-login code=%d loc=%q", rec.Code, rec.Header().Get("Location"))
+ }
+ cookies := rec.Result().Cookies()
+ if len(cookies) != 1 || cookies[0].Name != LauncherDashboardCookieName || cookies[0].Value != cookieVal {
+ t.Fatalf("cookies = %#v", cookies)
+ }
+ if cookies[0].MaxAge != 31*24*3600 {
+ t.Fatalf("session cookie MaxAge = %d, want 31 days", cookies[0].MaxAge)
}
- rec3 := httptest.NewRecorder()
- req3 := httptest.NewRequest(http.MethodGet, "/?token=", nil)
- h.ServeHTTP(rec3, req3)
- if rec3.Code != http.StatusFound {
- t.Fatalf("GET /?token=empty: status = %d, want redirect", rec3.Code)
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, "/", nil)
+ req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal})
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("cookie auth after auto-login status = %d", rec.Code)
}
- recLogin := httptest.NewRecorder()
- reqLogin := httptest.NewRequest(http.MethodGet, "/launcher-login?token="+tok, nil)
- h.ServeHTTP(recLogin, reqLogin)
- if recLogin.Code != http.StatusSeeOther || recLogin.Header().Get("Location") != "/" {
- t.Fatalf("GET /launcher-login?token=valid: code=%d loc=%q", recLogin.Code, recLogin.Header().Get("Location"))
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
+ req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal})
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
+ t.Fatalf("auto-login path with existing session code=%d loc=%q", rec.Code, rec.Header().Get("Location"))
+ }
+
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" {
+ t.Fatalf("consumed auto-login code=%d loc=%q", rec.Code, rec.Header().Get("Location"))
+ }
+}
+
+func TestLauncherDashboardAuth_LocalAutoLoginRequiresValidNonceAndUnexpired(t *testing.T) {
+ const cookieVal = "session-cookie-value"
+ newHandler := func(autoLogin *LauncherDashboardLocalAutoLogin) http.Handler {
+ return LauncherDashboardAuth(LauncherDashboardAuthConfig{
+ ExpectedCookie: cookieVal,
+ LocalAutoLogin: autoLogin,
+ }, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ }
+
+ autoLogin := mustLocalAutoLogin(t, time.Minute)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
+ req.RemoteAddr = "192.168.1.50:12345"
+ req.Host = "192.168.1.50:18800"
+ newHandler(autoLogin).ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther || len(rec.Result().Cookies()) != 1 {
+ t.Fatalf("capability auto-login code=%d cookies=%#v", rec.Code, rec.Result().Cookies())
+ }
+
+ expired := mustLocalAutoLogin(t, -time.Second)
+ h := newHandler(expired)
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, expired.URLPath(), nil)
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound || len(rec.Result().Cookies()) != 0 {
+ t.Fatalf("expired auto-login code=%d cookies=%#v", rec.Code, rec.Result().Cookies())
}
}
func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) {
- cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"}
+ cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("next handler should not run without auth")
})
@@ -131,14 +220,9 @@ func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) {
}
}
-func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) {
- key := make([]byte, 32)
- for i := range key {
- key[i] = 0xab
- }
- token := "dashboard-secret-9"
- cookieVal := SessionCookieValue(key, token)
- cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal, Token: token}
+func TestLauncherDashboardAuth_CookieOnly(t *testing.T) {
+ cookieVal := "session-cookie-value"
+ cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
@@ -153,10 +237,29 @@ func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) {
}
rec2 := httptest.NewRecorder()
- req2 := httptest.NewRequest(http.MethodGet, "/", nil)
- req2.Header.Set("Authorization", "Bearer "+token)
+ req2 := httptest.NewRequest(http.MethodGet, "/api/config", nil)
+ req2.Header.Set("Authorization", "Bearer dashboard-secret-9")
h.ServeHTTP(rec2, req2)
- if rec2.Code != http.StatusOK {
- t.Fatalf("bearer auth: status = %d", rec2.Code)
+ if rec2.Code != http.StatusUnauthorized {
+ t.Fatalf("bearer auth should not be accepted: status = %d", rec2.Code)
+ }
+}
+
+func TestLauncherDashboardAuth_WebSocketUnauthorizedDoesNotRedirect(t *testing.T) {
+ cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
+ next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
+ t.Fatal("next handler should not run without auth")
+ })
+ h := LauncherDashboardAuth(cfg, next)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/pico/ws", nil)
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
+ }
+ if got := rec.Header().Get("Location"); got != "" {
+ t.Fatalf("Location = %q, want empty", got)
}
}
diff --git a/web/backend/middleware/middleware.go b/web/backend/middleware/middleware.go
index 5e0dfeb90..f9eb3149d 100644
--- a/web/backend/middleware/middleware.go
+++ b/web/backend/middleware/middleware.go
@@ -1,7 +1,9 @@
package middleware
import (
+ "bufio"
"fmt"
+ "net"
"net/http"
"runtime/debug"
"time"
@@ -44,6 +46,15 @@ func (rr *responseRecorder) Unwrap() http.ResponseWriter {
return rr.ResponseWriter
}
+// Hijack implements http.Hijacker so that WebSocket upgrades work through
+// the middleware layer.
+func (rr *responseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+ if hj, ok := rr.ResponseWriter.(http.Hijacker); ok {
+ return hj.Hijack()
+ }
+ return nil, nil, http.ErrNotSupported
+}
+
// Logger logs each HTTP request with method, path, status code, and duration.
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -60,6 +71,7 @@ func Recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
+ logger.RecoverPanicNoExit(err)
logger.ErrorC("http", fmt.Sprintf("panic recovered: %v\n%s", err, debug.Stack()))
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
}
diff --git a/web/backend/middleware/referrer_policy.go b/web/backend/middleware/referrer_policy.go
index 5ac066614..6cb14669d 100644
--- a/web/backend/middleware/referrer_policy.go
+++ b/web/backend/middleware/referrer_policy.go
@@ -2,8 +2,8 @@ package middleware
import "net/http"
-// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response so sensitive
-// query parameters (e.g. ?token= for dashboard bootstrap) are not leaked via the Referer header.
+// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response
+// so sensitive paths and query parameters are not leaked via the Referer header.
func ReferrerPolicyNoReferrer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Referrer-Policy", "no-referrer")
diff --git a/web/backend/systray.go b/web/backend/systray.go
index 744ea4611..41fea1fbe 100644
--- a/web/backend/systray.go
+++ b/web/backend/systray.go
@@ -1,4 +1,4 @@
-//go:build (!darwin && !freebsd) || cgo
+//go:build !android && ((!darwin && !freebsd) || cgo)
package main
@@ -6,7 +6,6 @@ import (
"fmt"
"fyne.io/systray"
- "github.com/atotto/clipboard"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/web/backend/utils"
@@ -24,7 +23,6 @@ func onReady() {
// Create menu items
mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip))
- mCopyTok := systray.AddMenuItem(T(MenuCopyToken), T(MenuCopyTokenHint))
mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip))
// Add version info under About menu
@@ -52,17 +50,6 @@ func onReady() {
logger.Errorf("Failed to open browser: %v", err)
}
- case <-mCopyTok.ClickedCh:
- if launcherDashboardTokenForClipboard == "" {
- logger.WarnC("web", "Dashboard token is empty; cannot copy")
- continue
- }
- if err := clipboard.WriteAll(launcherDashboardTokenForClipboard); err != nil {
- logger.Errorf("Failed to copy dashboard token: %v", err)
- } else {
- logger.InfoC("web", "Dashboard token copied to clipboard")
- }
-
case <-mVersion.ClickedCh:
// Version info - do nothing, just shows current version
diff --git a/web/backend/systray_stub_nocgo.go b/web/backend/systray_stub_nocgo.go
index 9e75e112a..41514feef 100644
--- a/web/backend/systray_stub_nocgo.go
+++ b/web/backend/systray_stub_nocgo.go
@@ -1,4 +1,4 @@
-//go:build (darwin || freebsd) && !cgo
+//go:build (darwin || freebsd || android) && !cgo
package main
diff --git a/web/backend/tray_offers_copy.go b/web/backend/tray_offers_copy.go
deleted file mode 100644
index 6b7d17412..000000000
--- a/web/backend/tray_offers_copy.go
+++ /dev/null
@@ -1,5 +0,0 @@
-//go:build (!darwin && !freebsd) || cgo
-
-package main
-
-func trayOffersDashboardTokenCopy() bool { return true }
diff --git a/web/backend/tray_offers_copy_stub.go b/web/backend/tray_offers_copy_stub.go
deleted file mode 100644
index 9312700f3..000000000
--- a/web/backend/tray_offers_copy_stub.go
+++ /dev/null
@@ -1,5 +0,0 @@
-//go:build (darwin || freebsd) && !cgo
-
-package main
-
-func trayOffersDashboardTokenCopy() bool { return false }
diff --git a/web/backend/utils/runtime.go b/web/backend/utils/runtime.go
index 772cd7ec0..8899a664b 100644
--- a/web/backend/utils/runtime.go
+++ b/web/backend/utils/runtime.go
@@ -7,18 +7,16 @@ import (
"os/exec"
"path/filepath"
"runtime"
+ "strings"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
)
// GetPicoclawHome returns the picoclaw home directory.
// Priority: $PICOCLAW_HOME > ~/.picoclaw
func GetPicoclawHome() string {
- if home := os.Getenv(config.EnvHome); home != "" {
- return home
- }
- home, _ := os.UserHomeDir()
- return filepath.Join(home, ".picoclaw")
+ return config.GetHome()
}
// GetDefaultConfigPath returns the default path to the picoclaw config file.
@@ -47,6 +45,7 @@ func FindPicoclawBinary() string {
}
if exe, err := os.Executable(); err == nil {
+ logger.Debugf("Trying to find picoclaw binary in %s", exe)
candidate := filepath.Join(filepath.Dir(exe), binaryName)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
@@ -56,18 +55,93 @@ func FindPicoclawBinary() string {
return "picoclaw"
}
-// GetLocalIP returns the local IP address of the machine.
-func GetLocalIP() string {
+func appendUniqueIP(addrs []string, seen map[string]struct{}, value string) []string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return addrs
+ }
+ if _, ok := seen[value]; ok {
+ return addrs
+ }
+ seen[value] = struct{}{}
+ return append(addrs, value)
+}
+
+// GetLocalIPv4s returns all non-loopback local IPv4 addresses.
+func GetLocalIPv4s() []string {
addrs, err := net.InterfaceAddrs()
if err != nil {
- return ""
+ return nil
}
+ results := make([]string, 0, 4)
+ seen := make(map[string]struct{}, 4)
for _, a := range addrs {
- if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
- return ipnet.IP.String()
+ ipnet, ok := a.(*net.IPNet)
+ if !ok || ipnet.IP == nil || ipnet.IP.IsLoopback() {
+ continue
+ }
+ if ip4 := ipnet.IP.To4(); ip4 != nil {
+ results = appendUniqueIP(results, seen, ip4.String())
}
}
- return ""
+ return results
+}
+
+func isDisplayGlobalIPv6(ip net.IP) bool {
+ if ip == nil || ip.IsLoopback() || ip.To4() != nil {
+ return false
+ }
+ ip = ip.To16()
+ if ip == nil {
+ return false
+ }
+ // Only show IPv6 global unicast addresses in 2000::/3.
+ return ip[0]&0xe0 == 0x20
+}
+
+// GetGlobalIPv6s returns all IPv6 global unicast addresses.
+func GetGlobalIPv6s() []string {
+ addrs, err := net.InterfaceAddrs()
+ if err != nil {
+ return nil
+ }
+ results := make([]string, 0, 4)
+ seen := make(map[string]struct{}, 4)
+ for _, a := range addrs {
+ ipnet, ok := a.(*net.IPNet)
+ if !ok || ipnet.IP == nil {
+ continue
+ }
+ ip := ipnet.IP
+ if !isDisplayGlobalIPv6(ip) {
+ continue
+ }
+ results = appendUniqueIP(results, seen, ip.String())
+ }
+ return results
+}
+
+// GetLocalIPv4 returns the first non-loopback local IPv4 address.
+func GetLocalIPv4() string {
+ addrs := GetLocalIPv4s()
+ if len(addrs) == 0 {
+ return ""
+ }
+ return addrs[0]
+}
+
+// GetLocalIPv6 returns the first IPv6 global unicast address.
+func GetLocalIPv6() string {
+ addrs := GetGlobalIPv6s()
+ if len(addrs) == 0 {
+ return ""
+ }
+ return addrs[0]
+}
+
+// GetLocalIP returns a non-loopback local IPv4 address for backward compatibility.
+func GetLocalIP() string {
+ return GetLocalIPv4()
}
// OpenBrowser automatically opens the given URL in the default browser.
diff --git a/web/frontend/eslint.config.js b/web/frontend/eslint.config.js
index bc9c64344..884649e41 100644
--- a/web/frontend/eslint.config.js
+++ b/web/frontend/eslint.config.js
@@ -22,10 +22,19 @@ export default defineConfig([
globals: globals.browser,
},
rules: {
+ "react-hooks/set-state-in-effect": "off",
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
+ {
+ files: ["src/routes/**/*.{ts,tsx}"],
+ rules: {
+ // TanStack Router route modules must export Route objects, so this rule
+ // produces false positives for framework-managed files.
+ "react-refresh/only-export-components": "off",
+ },
+ },
])
diff --git a/web/frontend/package.json b/web/frontend/package.json
index 8053d1f2a..bf3e7921b 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -3,6 +3,10 @@
"private": true,
"version": "0.0.0",
"type": "module",
+ "packageManager": "pnpm@10.33.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
@@ -15,51 +19,53 @@
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@tabler/icons-react": "^3.40.0",
- "@tailwindcss/vite": "^4.2.2",
- "@tanstack/react-query": "^5.90.21",
- "@tanstack/react-router": "^1.167.0",
- "@tanstack/react-router-devtools": "^1.163.3",
+ "@tailwindcss/vite": "^4.2.4",
+ "@tanstack/react-query": "^5.99.0",
+ "@tanstack/react-router": "^1.169.2",
+ "@tanstack/react-router-devtools": "^1.166.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
- "i18next": "^25.8.14",
+ "highlight.js": "^11.11.1",
+ "i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
- "jotai": "^2.18.1",
+ "jotai": "^2.19.1",
"radix-ui": "^1.4.3",
- "react": "^19.2.0",
- "react-dom": "^19.2.0",
- "react-i18next": "^16.5.8",
+ "react": "19.2.5",
+ "react-dom": "19.2.5",
+ "react-i18next": "^17.0.4",
"react-markdown": "^10.1.0",
"react-textarea-autosize": "^8.5.9",
+ "rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
- "shadcn": "^4.1.0",
+ "shadcn": "^4.3.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
- "tailwindcss": "^4.2.2",
+ "tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0",
"wrap-ansi": "^10.0.0"
},
"devDependencies": {
- "@eslint/js": "^9.39.4",
+ "@eslint/js": "^10.0.1",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/router-plugin": "^1.164.0",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
- "@types/node": "^25.5.0",
+ "@types/node": "^25.6.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
- "@typescript-eslint/eslint-plugin": "^8.57.1",
- "@vitejs/plugin-react": "^5.2.0",
- "eslint": "^9.39.4",
+ "@typescript-eslint/eslint-plugin": "^8.58.2",
+ "@vitejs/plugin-react": "^6.0.1",
+ "eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8",
- "eslint-plugin-react-hooks": "^7.0.1",
- "eslint-plugin-react-refresh": "^0.4.26",
- "globals": "^16.5.0",
- "prettier": "^3.8.1",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "globals": "^17.5.0",
+ "prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "~5.9.3",
- "typescript-eslint": "^8.57.1",
- "vite": "^7.3.1"
+ "typescript-eslint": "^8.59.1",
+ "vite": "^8.0.10"
}
}
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index edaf49ccc..78639de19 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -13,19 +13,19 @@ importers:
version: 5.2.8
'@tabler/icons-react':
specifier: ^3.40.0
- version: 3.40.0(react@19.2.4)
+ version: 3.41.1(react@19.2.5)
'@tailwindcss/vite':
- specifier: ^4.2.2
- version: 4.2.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
+ specifier: ^4.2.4
+ version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))
'@tanstack/react-query':
- specifier: ^5.90.21
- version: 5.91.2(react@19.2.4)
+ specifier: ^5.99.0
+ version: 5.99.0(react@19.2.5)
'@tanstack/react-router':
- specifier: ^1.167.0
- version: 1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ specifier: ^1.169.2
+ version: 1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@tanstack/react-router-devtools':
- specifier: ^1.163.3
- version: 1.166.9(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.5)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ specifier: ^1.166.13
+ version: 1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -35,33 +35,39 @@ importers:
dayjs:
specifier: ^1.11.20
version: 1.11.20
+ highlight.js:
+ specifier: ^11.11.1
+ version: 11.11.1
i18next:
- specifier: ^25.8.14
- version: 25.8.20(typescript@5.9.3)
+ specifier: ^26.0.8
+ version: 26.0.8(typescript@5.9.3)
i18next-browser-languagedetector:
specifier: ^8.2.1
version: 8.2.1
jotai:
- specifier: ^2.18.1
- version: 2.18.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4)
+ specifier: ^2.19.1
+ version: 2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5)
radix-ui:
specifier: ^1.4.3
- version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react:
- specifier: ^19.2.0
- version: 19.2.4
+ specifier: 19.2.5
+ version: 19.2.5
react-dom:
- specifier: ^19.2.0
- version: 19.2.4(react@19.2.4)
+ specifier: 19.2.5
+ version: 19.2.5(react@19.2.5)
react-i18next:
- specifier: ^16.5.8
- version: 16.5.8(i18next@25.8.20(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
+ specifier: ^17.0.4
+ version: 17.0.4(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)
react-markdown:
specifier: ^10.1.0
- version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
+ version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
react-textarea-autosize:
specifier: ^8.5.9
- version: 8.5.9(@types/react@19.2.14)(react@19.2.4)
+ version: 8.5.9(@types/react@19.2.14)(react@19.2.5)
+ rehype-highlight:
+ specifier: ^7.0.2
+ version: 7.0.2
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
@@ -72,17 +78,17 @@ importers:
specifier: ^4.0.1
version: 4.0.1
shadcn:
- specifier: ^4.1.0
- version: 4.1.0(@types/node@25.5.0)(typescript@5.9.3)
+ specifier: ^4.3.0
+ version: 4.3.0(@types/node@25.6.0)(typescript@5.9.3)
sonner:
specifier: ^2.0.7
- version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
tailwindcss:
- specifier: ^4.2.2
- version: 4.2.2
+ specifier: ^4.2.4
+ version: 4.2.4
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
@@ -91,20 +97,20 @@ importers:
version: 10.0.0
devDependencies:
'@eslint/js':
- specifier: ^9.39.4
- version: 9.39.4
+ specifier: ^10.0.1
+ version: 10.0.1(eslint@10.2.1(jiti@2.7.0))
'@tailwindcss/typography':
specifier: ^0.5.19
- version: 0.5.19(tailwindcss@4.2.2)
+ version: 0.5.19(tailwindcss@4.2.4)
'@tanstack/router-plugin':
specifier: ^1.164.0
- version: 1.166.14(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
+ version: 1.167.9(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))
'@trivago/prettier-plugin-sort-imports':
specifier: ^6.0.2
- version: 6.0.2(prettier@3.8.1)
+ version: 6.0.2(prettier@3.8.3)
'@types/node':
- specifier: ^25.5.0
- version: 25.5.0
+ specifier: ^25.6.0
+ version: 25.6.0
'@types/react':
specifier: ^19.2.7
version: 19.2.14
@@ -112,41 +118,41 @@ importers:
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@typescript-eslint/eslint-plugin':
- specifier: ^8.57.1
- version: 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ specifier: ^8.58.2
+ version: 8.58.2(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
'@vitejs/plugin-react':
- specifier: ^5.2.0
- version: 5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
+ specifier: ^6.0.1
+ version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))
eslint:
- specifier: ^9.39.4
- version: 9.39.4(jiti@2.6.1)
+ specifier: ^10.2.1
+ version: 10.2.1(jiti@2.7.0)
eslint-config-prettier:
specifier: ^10.1.8
- version: 10.1.8(eslint@9.39.4(jiti@2.6.1))
+ version: 10.1.8(eslint@10.2.1(jiti@2.7.0))
eslint-plugin-react-hooks:
- specifier: ^7.0.1
- version: 7.0.1(eslint@9.39.4(jiti@2.6.1))
+ specifier: ^7.1.1
+ version: 7.1.1(eslint@10.2.1(jiti@2.7.0))
eslint-plugin-react-refresh:
- specifier: ^0.4.26
- version: 0.4.26(eslint@9.39.4(jiti@2.6.1))
+ specifier: ^0.5.2
+ version: 0.5.2(eslint@10.2.1(jiti@2.7.0))
globals:
- specifier: ^16.5.0
- version: 16.5.0
+ specifier: ^17.5.0
+ version: 17.5.0
prettier:
- specifier: ^3.8.1
- version: 3.8.1
+ specifier: ^3.8.3
+ version: 3.8.3
prettier-plugin-tailwindcss:
specifier: ^0.7.2
- version: 0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1))(prettier@3.8.1)
+ version: 0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3))(prettier@3.8.3)
typescript:
specifier: ~5.9.3
version: 5.9.3
typescript-eslint:
- specifier: ^8.57.1
- version: 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ specifier: ^8.59.1
+ version: 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
vite:
- specifier: ^7.3.1
- version: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
+ specifier: ^8.0.10
+ version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)
packages:
@@ -255,18 +261,6 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
- '@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/plugin-transform-typescript@7.28.6':
resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==}
engines: {node: '>=6.9.0'}
@@ -295,16 +289,25 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
- '@dotenvx/dotenvx@1.57.0':
- resolution: {integrity: sha512-WsTEcqfHzKmLFZh3jLGd7o4iCkrIupp+qFH2FJUJtQXUh2GcOnLXD00DcrhlO4H8QSmaKnW9lugOEbrdpu25kA==}
+ '@dotenvx/dotenvx@1.61.0':
+ resolution: {integrity: sha512-utL3cpZoFzflyqUkjYbxYujI6STBTmO5LFn4bbin/NZnRWN6wQ7eErhr3/Vpa5h/jicPFC6kTa42r940mQftJQ==}
hasBin: true
- '@ecies/ciphers@0.2.5':
- resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==}
- engines: {bun: '>=1', deno: '>=2', node: '>=16'}
+ '@ecies/ciphers@0.2.6':
+ resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==}
+ engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'}
peerDependencies:
'@noble/ciphers': ^1.0.0
+ '@emnapi/core@1.10.0':
+ resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
+
+ '@emnapi/runtime@1.10.0':
+ resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
+
+ '@emnapi/wasi-threads@1.2.1':
+ resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
+
'@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'}
@@ -471,33 +474,34 @@ packages:
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
- '@eslint/config-array@0.21.2':
- resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/config-array@0.23.5':
+ resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
- '@eslint/config-helpers@0.4.2':
- resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/config-helpers@0.5.5':
+ resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
- '@eslint/core@0.17.0':
- resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/core@1.2.1':
+ resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
- '@eslint/eslintrc@3.3.5':
- resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/js@10.0.1':
+ resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ peerDependencies:
+ eslint: ^10.0.0
+ peerDependenciesMeta:
+ eslint:
+ optional: true
- '@eslint/js@9.39.4':
- resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/object-schema@3.0.5':
+ resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
- '@eslint/object-schema@2.1.7':
- resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
- '@eslint/plugin-kit@0.4.1':
- resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/plugin-kit@0.7.1':
+ resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
@@ -517,8 +521,8 @@ packages:
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
- '@hono/node-server@1.19.11':
- resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
+ '@hono/node-server@1.19.14':
+ resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
@@ -539,35 +543,35 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
- '@inquirer/ansi@1.0.2':
- resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==}
- engines: {node: '>=18'}
+ '@inquirer/ansi@2.0.5':
+ resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
- '@inquirer/confirm@5.1.21':
- resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==}
- engines: {node: '>=18'}
+ '@inquirer/confirm@6.0.11':
+ resolution: {integrity: sha512-pTpHjg0iEIRMYV/7oCZUMf27/383E6Wyhfc/MY+AVQGEoUobffIYWOK9YLP2XFRGz/9i6WlTQh1CkFVIo2Y7XA==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
- '@inquirer/core@10.3.2':
- resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==}
- engines: {node: '>=18'}
+ '@inquirer/core@11.1.8':
+ resolution: {integrity: sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
- '@inquirer/figures@1.0.15':
- resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==}
- engines: {node: '>=18'}
+ '@inquirer/figures@2.0.5':
+ resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
- '@inquirer/type@3.0.10':
- resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==}
- engines: {node: '>=18'}
+ '@inquirer/type@4.0.5':
+ resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
@@ -590,8 +594,8 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
- '@modelcontextprotocol/sdk@1.27.1':
- resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==}
+ '@modelcontextprotocol/sdk@1.29.0':
+ resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
engines: {node: '>=18'}
peerDependencies:
'@cfworker/json-schema': ^4.1.1
@@ -604,6 +608,12 @@ packages:
resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==}
engines: {node: '>=18'}
+ '@napi-rs/wasm-runtime@1.1.4':
+ resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1
+ '@emnapi/runtime': ^1.7.1
+
'@noble/ciphers@1.3.0':
resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
engines: {node: ^14.21.3 || >=16}
@@ -631,12 +641,18 @@ packages:
'@open-draft/deferred-promise@2.2.0':
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
+ '@open-draft/deferred-promise@3.0.0':
+ resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==}
+
'@open-draft/logger@0.3.0':
resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==}
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
+ '@oxc-project/types@0.127.0':
+ resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==}
+
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -1327,133 +1343,106 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
- '@rolldown/pluginutils@1.0.0-rc.3':
- resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
-
- '@rollup/rollup-android-arm-eabi@4.59.0':
- resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
- cpu: [arm]
- os: [android]
-
- '@rollup/rollup-android-arm64@4.59.0':
- resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==}
+ '@rolldown/binding-android-arm64@1.0.0-rc.17':
+ resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
- '@rollup/rollup-darwin-arm64@4.59.0':
- resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==}
+ '@rolldown/binding-darwin-arm64@1.0.0-rc.17':
+ resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
- '@rollup/rollup-darwin-x64@4.59.0':
- resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==}
+ '@rolldown/binding-darwin-x64@1.0.0-rc.17':
+ resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
- '@rollup/rollup-freebsd-arm64@4.59.0':
- resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==}
- cpu: [arm64]
- os: [freebsd]
-
- '@rollup/rollup-freebsd-x64@4.59.0':
- resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==}
+ '@rolldown/binding-freebsd-x64@1.0.0-rc.17':
+ resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
- '@rollup/rollup-linux-arm-gnueabihf@4.59.0':
- resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':
+ resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@rollup/rollup-linux-arm-musleabihf@4.59.0':
- resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
- cpu: [arm]
- os: [linux]
-
- '@rollup/rollup-linux-arm64-gnu@4.59.0':
- resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
+ resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
- '@rollup/rollup-linux-arm64-musl@4.59.0':
- resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
+ '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
+ resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
- '@rollup/rollup-linux-loong64-gnu@4.59.0':
- resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
- cpu: [loong64]
- os: [linux]
-
- '@rollup/rollup-linux-loong64-musl@4.59.0':
- resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
- cpu: [loong64]
- os: [linux]
-
- '@rollup/rollup-linux-ppc64-gnu@4.59.0':
- resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
+ resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
- '@rollup/rollup-linux-ppc64-musl@4.59.0':
- resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
- cpu: [ppc64]
- os: [linux]
-
- '@rollup/rollup-linux-riscv64-gnu@4.59.0':
- resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
- cpu: [riscv64]
- os: [linux]
-
- '@rollup/rollup-linux-riscv64-musl@4.59.0':
- resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
- cpu: [riscv64]
- os: [linux]
-
- '@rollup/rollup-linux-s390x-gnu@4.59.0':
- resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
+ '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
+ resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
- '@rollup/rollup-linux-x64-gnu@4.59.0':
- resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
+ '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
+ resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
- '@rollup/rollup-linux-x64-musl@4.59.0':
- resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
+ '@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
+ resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
- '@rollup/rollup-openbsd-x64@4.59.0':
- resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
- cpu: [x64]
- os: [openbsd]
-
- '@rollup/rollup-openharmony-arm64@4.59.0':
- resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==}
+ '@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
+ resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
- '@rollup/rollup-win32-arm64-msvc@4.59.0':
- resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==}
+ '@rolldown/binding-wasm32-wasi@1.0.0-rc.17':
+ resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [wasm32]
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17':
+ resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
- '@rollup/rollup-win32-ia32-msvc@4.59.0':
- resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==}
- cpu: [ia32]
- os: [win32]
-
- '@rollup/rollup-win32-x64-gnu@4.59.0':
- resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==}
+ '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':
+ resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
- '@rollup/rollup-win32-x64-msvc@4.59.0':
- resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==}
- cpu: [x64]
- os: [win32]
+ '@rolldown/pluginutils@1.0.0-rc.17':
+ resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==}
+
+ '@rolldown/pluginutils@1.0.0-rc.7':
+ resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
@@ -1462,73 +1451,77 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
- '@tabler/icons-react@3.40.0':
- resolution: {integrity: sha512-oO5+6QCnna4a//mYubx4euZfECtzQZFDGsDMIdzZUhbdyBCT+3bRVFBPueGIcemWld4Vb/0UQ39C/cmGfGylAg==}
+ '@tabler/icons-react@3.41.1':
+ resolution: {integrity: sha512-kUgweE+DJtAlMZVIns1FTDdcbpRVnkK7ZpUOXmoxy3JAF0rSHj0TcP4VHF14+gMJGnF+psH2Zt26BLT6owetBA==}
peerDependencies:
react: '>= 16'
- '@tabler/icons@3.40.0':
- resolution: {integrity: sha512-V/Q4VgNPKubRTiLdmWjV/zscYcj5IIk+euicUtaVVqF6luSC9rDngYWgST5/yh3Mrg/mYUwRv1YVTk71Jp0twQ==}
+ '@tabler/icons@3.41.1':
+ resolution: {integrity: sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q==}
- '@tailwindcss/node@4.2.2':
- resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
+ '@tailwindcss/node@4.2.4':
+ resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==}
- '@tailwindcss/oxide-android-arm64@4.2.2':
- resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==}
+ '@tailwindcss/oxide-android-arm64@4.2.4':
+ resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [android]
- '@tailwindcss/oxide-darwin-arm64@4.2.2':
- resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==}
+ '@tailwindcss/oxide-darwin-arm64@4.2.4':
+ resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [darwin]
- '@tailwindcss/oxide-darwin-x64@4.2.2':
- resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==}
+ '@tailwindcss/oxide-darwin-x64@4.2.4':
+ resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [darwin]
- '@tailwindcss/oxide-freebsd-x64@4.2.2':
- resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==}
+ '@tailwindcss/oxide-freebsd-x64@4.2.4':
+ resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [freebsd]
- '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
- resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==}
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4':
+ resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==}
engines: {node: '>= 20'}
cpu: [arm]
os: [linux]
- '@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
- resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==}
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.4':
+ resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==}
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==}
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.4':
+ resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==}
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==}
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.4':
+ resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
- '@tailwindcss/oxide-linux-x64-musl@4.2.2':
- resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
+ '@tailwindcss/oxide-linux-x64-musl@4.2.4':
+ resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
+ libc: [musl]
- '@tailwindcss/oxide-wasm32-wasi@4.2.2':
- resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
+ '@tailwindcss/oxide-wasm32-wasi@4.2.4':
+ resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
@@ -1539,20 +1532,20 @@ packages:
- '@emnapi/wasi-threads'
- tslib
- '@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
- resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==}
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.4':
+ resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [win32]
- '@tailwindcss/oxide-win32-x64-msvc@4.2.2':
- resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==}
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.4':
+ resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [win32]
- '@tailwindcss/oxide@4.2.2':
- resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==}
+ '@tailwindcss/oxide@4.2.4':
+ resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==}
engines: {node: '>= 20'}
'@tailwindcss/typography@0.5.19':
@@ -1560,8 +1553,8 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
- '@tailwindcss/vite@4.2.2':
- resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==}
+ '@tailwindcss/vite@4.2.4':
+ resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==}
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
@@ -1569,65 +1562,69 @@ packages:
resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==}
engines: {node: '>=20.19'}
- '@tanstack/query-core@5.91.2':
- resolution: {integrity: sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==}
+ '@tanstack/query-core@5.99.0':
+ resolution: {integrity: sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==}
- '@tanstack/react-query@5.91.2':
- resolution: {integrity: sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==}
+ '@tanstack/react-query@5.99.0':
+ resolution: {integrity: sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==}
peerDependencies:
react: ^18 || ^19
- '@tanstack/react-router-devtools@1.166.9':
- resolution: {integrity: sha512-O49eZmaeEKB5YnKH/qd61AbxV/lW8ICm4stfZ4GNQNpzQQ6rhPIB0p3PMZDIgX+6DoMivdNvLRmXAOOpzpIpDg==}
+ '@tanstack/react-router-devtools@1.166.13':
+ resolution: {integrity: sha512-6yKRFFJrEEOiGp5RAAuGCYsl81M4XAhJmLcu9PKj+HZle4A3dsP60lwHoqQYWHMK9nKKFkdXR+D8qxzxqtQbEA==}
engines: {node: '>=20.19'}
peerDependencies:
- '@tanstack/react-router': ^1.167.2
- '@tanstack/router-core': ^1.167.2
+ '@tanstack/react-router': ^1.168.15
+ '@tanstack/router-core': ^1.168.11
react: '>=18.0.0 || >=19.0.0'
react-dom: '>=18.0.0 || >=19.0.0'
peerDependenciesMeta:
'@tanstack/router-core':
optional: true
- '@tanstack/react-router@1.167.5':
- resolution: {integrity: sha512-s1nP6l/7BYZfSwhoNbB7/rUmZ07q/AvkmhBoiDQl3tgy5dpb9Q1qjtIapYdvCOrao1aA/QCaWqxcbGc2Ct1bvQ==}
+ '@tanstack/react-router@1.169.2':
+ resolution: {integrity: sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ==}
engines: {node: '>=20.19'}
peerDependencies:
react: '>=18.0.0 || >=19.0.0'
react-dom: '>=18.0.0 || >=19.0.0'
- '@tanstack/react-store@0.9.2':
- resolution: {integrity: sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==}
+ '@tanstack/react-store@0.9.3':
+ resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
- '@tanstack/router-core@1.167.5':
- resolution: {integrity: sha512-8fRgJ0zNJf77R4grCaJQ5Imatjyc4YT5v8rlsPkYYYeUlcFNLbuFRhLlAMdND9gRUMznpnbRDXngpTPgx2K7HQ==}
+ '@tanstack/router-core@1.168.7':
+ resolution: {integrity: sha512-z4UEdlzMrFaKBsG4OIxlZEm+wsYBtEp//fnX6kW18jhQpETNcM6u2SXNdX+bcIYp6AaR7ERS3SBENzjC/xxwQQ==}
engines: {node: '>=20.19'}
hasBin: true
- '@tanstack/router-devtools-core@1.166.9':
- resolution: {integrity: sha512-PNlA7GmOUX9wY7LUG709Pk3Lg33dfHBztQwzjzrOiOsuf4ggp2R6bwarF8nYGNjG79z/MaB5PN+5yvkCVk8jGw==}
+ '@tanstack/router-core@1.169.2':
+ resolution: {integrity: sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw==}
+ engines: {node: '>=20.19'}
+
+ '@tanstack/router-devtools-core@1.167.3':
+ resolution: {integrity: sha512-fJ1VMhyQgnoashTrP763c2HRc9kofgF61L7Jb3F6eTHAmCKtGVx8BRtiFt37sr3U0P0jmaaiiSPGP6nT5JtVNg==}
engines: {node: '>=20.19'}
peerDependencies:
- '@tanstack/router-core': ^1.167.2
+ '@tanstack/router-core': ^1.168.11
csstype: ^3.0.10
peerDependenciesMeta:
csstype:
optional: true
- '@tanstack/router-generator@1.166.13':
- resolution: {integrity: sha512-ALxSs6OzimiSgpOuIm+AXmc7eUx/oGPwSPpdQbpZ/kX7WHRh6qM7lv8DAN0K3jWcBpzF8eeOIdryWryX8gH+Yg==}
+ '@tanstack/router-generator@1.166.22':
+ resolution: {integrity: sha512-wQ7H8/Q2rmSPuaxWnurJ3DATNnqWV2tajxri9TSiW4QHsG7cWPD34+goeIinKG+GajJyEdfVpz6w/gRJXfbAPw==}
engines: {node: '>=20.19'}
- '@tanstack/router-plugin@1.166.14':
- resolution: {integrity: sha512-hypyj0qlsAbJf60/glmVYqSVwnRB4hKRrMCUsSXjrPdO2g6gs3z6xHmcWsHQ831C4G9+bSFEK9Uy5EjO3A4THQ==}
+ '@tanstack/router-plugin@1.167.9':
+ resolution: {integrity: sha512-h/VV05FEHd4PVyc5Zy8B3trWLcdLt/Pmp+mfifmBKGRw+MUtvdQKbBHhmy4ouOf67s5zDJMc+n8R3xgU7bDwFA==}
engines: {node: '>=20.19'}
hasBin: true
peerDependencies:
'@rsbuild/core': '>=1.0.2'
- '@tanstack/react-router': ^1.167.5
+ '@tanstack/react-router': ^1.168.8
vite: '>=5.0.0 || >=6.0.0 || >=7.0.0'
vite-plugin-solid: ^2.11.10
webpack: '>=5.92.0'
@@ -1647,8 +1644,8 @@ packages:
resolution: {integrity: sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw==}
engines: {node: '>=20.19'}
- '@tanstack/store@0.9.2':
- resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==}
+ '@tanstack/store@0.9.3':
+ resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
'@tanstack/virtual-file-routes@1.161.7':
resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==}
@@ -1677,21 +1674,15 @@ packages:
'@ts-morph/common@0.27.0':
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
- '@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==}
+ '@tybys/wasm-util@0.10.1':
+ resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
+ '@types/esrecurse@4.3.1':
+ resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
+
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@@ -1710,8 +1701,8 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
- '@types/node@25.5.0':
- resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
+ '@types/node@25.6.0':
+ resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
@@ -1721,6 +1712,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
+ '@types/set-cookie-parser@2.4.10':
+ resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==}
+
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
@@ -1733,73 +1727,133 @@ packages:
'@types/validate-npm-package-name@4.0.2':
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
- '@typescript-eslint/eslint-plugin@8.57.1':
- resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
+ '@typescript-eslint/eslint-plugin@8.58.2':
+ resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- '@typescript-eslint/parser': ^8.57.1
+ '@typescript-eslint/parser': ^8.58.2
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
- typescript: '>=4.8.4 <6.0.0'
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/parser@8.57.1':
- resolution: {integrity: sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==}
+ '@typescript-eslint/eslint-plugin@8.59.1':
+ resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.59.1
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/parser@8.59.1':
+ resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
- typescript: '>=4.8.4 <6.0.0'
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/project-service@8.57.1':
- resolution: {integrity: sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==}
+ '@typescript-eslint/project-service@8.58.2':
+ resolution: {integrity: sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- typescript: '>=4.8.4 <6.0.0'
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/scope-manager@8.57.1':
- resolution: {integrity: sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
- '@typescript-eslint/tsconfig-utils@8.57.1':
- resolution: {integrity: sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==}
+ '@typescript-eslint/project-service@8.59.1':
+ resolution: {integrity: sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- typescript: '>=4.8.4 <6.0.0'
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/type-utils@8.57.1':
- resolution: {integrity: sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==}
+ '@typescript-eslint/scope-manager@8.58.2':
+ resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/scope-manager@8.59.1':
+ resolution: {integrity: sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/tsconfig-utils@8.58.2':
+ resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/tsconfig-utils@8.59.1':
+ resolution: {integrity: sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/type-utils@8.58.2':
+ resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
- typescript: '>=4.8.4 <6.0.0'
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/types@8.57.1':
- resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
- '@typescript-eslint/typescript-estree@8.57.1':
- resolution: {integrity: sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- peerDependencies:
- typescript: '>=4.8.4 <6.0.0'
-
- '@typescript-eslint/utils@8.57.1':
- resolution: {integrity: sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==}
+ '@typescript-eslint/type-utils@8.59.1':
+ resolution: {integrity: sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
- typescript: '>=4.8.4 <6.0.0'
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/visitor-keys@8.57.1':
- resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==}
+ '@typescript-eslint/types@8.58.2':
+ resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/types@8.59.1':
+ resolution: {integrity: sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.58.2':
+ resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/typescript-estree@8.59.1':
+ resolution: {integrity: sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/utils@8.58.2':
+ resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/utils@8.59.1':
+ resolution: {integrity: sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/visitor-keys@8.58.2':
+ resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/visitor-keys@8.59.1':
+ resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+ deprecated: Potential CWE-502 - Update to 1.3.1 or higher
- '@vitejs/plugin-react@5.2.0':
- resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
+ '@vitejs/plugin-react@6.0.1':
+ resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
- vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+ '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
+ babel-plugin-react-compiler: ^1.0.0
+ vite: ^8.0.0
+ peerDependenciesMeta:
+ '@rolldown/plugin-babel':
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
@@ -1881,8 +1935,8 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
- baseline-browser-mapping@2.10.9:
- resolution: {integrity: sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==}
+ baseline-browser-mapping@2.10.17:
+ resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -1894,22 +1948,19 @@ packages:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
- brace-expansion@1.1.12:
- resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
+ brace-expansion@2.0.3:
+ resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==}
- brace-expansion@2.0.2:
- resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
-
- brace-expansion@5.0.4:
- resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==}
+ brace-expansion@5.0.5:
+ resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
- browserslist@4.28.1:
- resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
+ browserslist@4.28.2:
+ resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
@@ -1933,16 +1984,12 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
- caniuse-lite@1.0.30001780:
- resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==}
+ caniuse-lite@1.0.30001787:
+ resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
- chalk@4.1.2:
- resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
- engines: {node: '>=10'}
-
chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
@@ -2007,11 +2054,8 @@ packages:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
- concat-map@0.0.1:
- resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
-
- content-disposition@1.0.1:
- resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==}
+ content-disposition@1.1.0:
+ resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
engines: {node: '>=18'}
content-type@1.0.5:
@@ -2024,6 +2068,9 @@ packages:
cookie-es@2.0.0:
resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==}
+ cookie-es@3.1.1:
+ resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
+
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
@@ -2125,12 +2172,12 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
- diff@8.0.3:
- resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
+ diff@8.0.4:
+ resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
- dotenv@17.3.1:
- resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==}
+ dotenv@17.4.2:
+ resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'}
dunder-proto@1.0.1:
@@ -2144,8 +2191,8 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
- electron-to-chromium@1.5.321:
- resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==}
+ electron-to-chromium@1.5.334:
+ resolution: {integrity: sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -2157,8 +2204,8 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
- enhanced-resolve@5.20.1:
- resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
+ enhanced-resolve@5.21.0:
+ resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
engines: {node: '>=10.13.0'}
entities@6.0.1:
@@ -2210,36 +2257,32 @@ packages:
peerDependencies:
eslint: '>=7.0.0'
- eslint-plugin-react-hooks@7.0.1:
- resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==}
+ eslint-plugin-react-hooks@7.1.1:
+ resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
engines: {node: '>=18'}
peerDependencies:
- eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
+ eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
- eslint-plugin-react-refresh@0.4.26:
- resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==}
+ eslint-plugin-react-refresh@0.5.2:
+ resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==}
peerDependencies:
- eslint: '>=8.40'
+ eslint: ^9 || ^10
- eslint-scope@8.4.0:
- resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ eslint-scope@9.1.2:
+ resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint-visitor-keys@3.4.3:
resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
- eslint-visitor-keys@4.2.1:
- resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
eslint-visitor-keys@5.0.1:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
- eslint@9.39.4:
- resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ eslint@10.2.1:
+ resolution: {integrity: sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
hasBin: true
peerDependencies:
jiti: '*'
@@ -2247,9 +2290,9 @@ packages:
jiti:
optional: true
- espree@10.4.0:
- resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ espree@11.2.0:
+ resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
esprima@4.0.1:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
@@ -2295,8 +2338,8 @@ packages:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
- express-rate-limit@8.3.1:
- resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==}
+ express-rate-limit@8.3.2:
+ resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
@@ -2321,9 +2364,18 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+ fast-string-truncated-width@3.0.3:
+ resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
+
+ fast-string-width@3.0.2:
+ resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
+
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
+ fast-wrap-ansi@0.2.0:
+ resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==}
+
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -2430,8 +2482,8 @@ packages:
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
engines: {node: '>=18'}
- get-tsconfig@4.13.6:
- resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
+ get-tsconfig@4.13.7:
+ resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
@@ -2441,12 +2493,8 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
- globals@14.0.0:
- resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
- engines: {node: '>=18'}
-
- globals@16.5.0:
- resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
+ globals@17.5.0:
+ resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==}
engines: {node: '>=18'}
goober@2.1.18:
@@ -2461,14 +2509,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
- graphql@16.13.1:
- resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==}
+ graphql@16.13.2:
+ resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
- has-flag@4.0.0:
- resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
- engines: {node: '>=8'}
-
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
@@ -2480,6 +2524,9 @@ packages:
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
+ hast-util-is-element@3.0.0:
+ resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
+
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
@@ -2495,14 +2542,17 @@ packages:
hast-util-to-parse5@8.0.1:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
+ hast-util-to-text@4.0.2:
+ resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
+
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
- headers-polyfill@4.0.3:
- resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
+ headers-polyfill@5.0.1:
+ resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==}
hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
@@ -2510,8 +2560,12 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
- hono@4.12.8:
- resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==}
+ highlight.js@11.11.1:
+ resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
+ engines: {node: '>=12.0.0'}
+
+ hono@4.12.14:
+ resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==}
engines: {node: '>=16.9.0'}
html-parse-stringify@3.0.1:
@@ -2542,10 +2596,10 @@ packages:
i18next-browser-languagedetector@8.2.1:
resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==}
- i18next@25.8.20:
- resolution: {integrity: sha512-xjo9+lbX/P1tQt3xpO2rfJiBppNfUnNIPKgCvNsTKsvTOCro1Qr/geXVg1N47j5ScOSaXAPq8ET93raK3Rr06A==}
+ i18next@26.0.8:
+ resolution: {integrity: sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==}
peerDependencies:
- typescript: ^5
+ typescript: ^5 || ^6
peerDependenciesMeta:
typescript:
optional: true
@@ -2675,8 +2729,8 @@ packages:
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
engines: {node: '>=16'}
- isbot@5.1.36:
- resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==}
+ isbot@5.1.40:
+ resolution: {integrity: sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ==}
engines: {node: '>=18'}
isexe@2.0.0:
@@ -2689,15 +2743,15 @@ packages:
javascript-natural-sort@0.7.1:
resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==}
- jiti@2.6.1:
- resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
+ jiti@2.7.0:
+ resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
jose@6.2.2:
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
- jotai@2.18.1:
- resolution: {integrity: sha512-e0NOzK+yRFwHo7DOp0DS0Ycq74KMEAObDWFGmfEL28PD9nLqBTt3/Ug7jf9ca72x0gC9LQZG9zH+0ISICmy3iA==}
+ jotai@2.19.1:
+ resolution: {integrity: sha512-sqm9lVZiqBHZH8aSRk32DSiZDHY3yUIlulXYn9GQj7/LvoUdYXSMti7ZPJGo+6zjzKFt5a25k/I6iBCi43PJcw==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@babel/core': '>=7.0.0'
@@ -2802,24 +2856,28 @@ packages:
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==}
@@ -2847,9 +2905,6 @@ packages:
lodash-es@4.17.23:
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
- lodash.merge@4.6.2:
- resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
-
log-symbols@6.0.0:
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
engines: {node: '>=18'}
@@ -2857,6 +2912,9 @@ packages:
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+ lowlight@3.3.0:
+ resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
+
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -3034,13 +3092,10 @@ packages:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
- minimatch@10.2.4:
- resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
+ minimatch@10.2.5:
+ resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
- minimatch@3.1.5:
- resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
-
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -3051,8 +3106,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
- msw@2.12.13:
- resolution: {integrity: sha512-9CV2mXT9+z0J26MQDfEZZkj/psJ5Er/w0w+t95FWdaGH/DTlhNZBx8vBO5jSYv8AZEnl3ouX+AaTT68KXdAIag==}
+ msw@2.13.4:
+ resolution: {integrity: sha512-fPlKBeFe+8rpcyR3umUmmHuNwu6gc6T3STvkgEa9WDX/HEgal9wDeflpCUAIRtmvaLZM2igfI5y1bZ9G5J26KA==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
@@ -3061,9 +3116,9 @@ packages:
typescript:
optional: true
- mute-stream@2.0.0:
- resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
- engines: {node: ^18.17.0 || >=20.5.0}
+ mute-stream@3.0.0:
+ resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
+ engines: {node: ^20.17.0 || >=22.9.0}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
@@ -3086,8 +3141,8 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
- node-releases@2.0.36:
- resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
+ node-releases@2.0.37:
+ resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
@@ -3197,8 +3252,8 @@ packages:
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
- path-to-regexp@8.3.0:
- resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
+ path-to-regexp@8.4.2:
+ resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -3206,12 +3261,12 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
- picomatch@2.3.1:
- resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+ picomatch@2.3.2:
+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
- picomatch@4.0.3:
- resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pkce-challenge@5.0.1:
@@ -3226,8 +3281,8 @@ packages:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
engines: {node: '>=4'}
- postcss@8.5.8:
- resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
+ postcss@8.5.10:
+ resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
engines: {node: ^10 || ^12 || >=14}
powershell-utils@0.1.0:
@@ -3293,8 +3348,8 @@ packages:
prettier-plugin-svelte:
optional: true
- prettier@3.8.1:
- resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
+ prettier@3.8.3:
+ resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
engines: {node: '>=14'}
hasBin: true
@@ -3317,8 +3372,8 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
- qs@6.15.0:
- resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
+ qs@6.15.1:
+ resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
engines: {node: '>=0.6'}
queue-microtask@1.2.3:
@@ -3345,19 +3400,19 @@ packages:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
- react-dom@19.2.4:
- resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
+ react-dom@19.2.5:
+ resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
peerDependencies:
- react: ^19.2.4
+ react: ^19.2.5
- react-i18next@16.5.8:
- resolution: {integrity: sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==}
+ react-i18next@17.0.4:
+ resolution: {integrity: sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==}
peerDependencies:
- i18next: '>= 25.6.2'
+ i18next: '>= 26.0.1'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
- typescript: ^5
+ typescript: ^5 || ^6
peerDependenciesMeta:
react-dom:
optional: true
@@ -3372,10 +3427,6 @@ packages:
'@types/react': '>=18'
react: '>=18'
- react-refresh@0.18.0:
- resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
- engines: {node: '>=0.10.0'}
-
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
@@ -3412,8 +3463,8 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
- react@19.2.4:
- resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
+ react@19.2.5:
+ resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'}
readdirp@3.6.0:
@@ -3424,6 +3475,9 @@ packages:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
+ rehype-highlight@7.0.2:
+ resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
+
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
@@ -3461,16 +3515,16 @@ packages:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
- rettime@0.10.1:
- resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==}
+ rettime@0.11.7:
+ resolution: {integrity: sha512-DoAm1WjR1eH7z8sHPtvvUMIZh4/CSKkGCz6CxPqOrEAnOGtOuHSnSE9OC+razqxKuf4ub7pAYyl/vZV0vGs5tg==}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
- rollup@4.59.0:
- resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
- engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ rolldown@1.0.0-rc.17:
+ resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
router@2.2.0:
@@ -3509,19 +3563,32 @@ packages:
peerDependencies:
seroval: ^1.0
+ seroval-plugins@1.5.4:
+ resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ seroval: ^1.0
+
seroval@1.5.1:
resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
engines: {node: '>=10'}
+ seroval@1.5.4:
+ resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==}
+ engines: {node: '>=10'}
+
serve-static@2.2.1:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
+ set-cookie-parser@3.1.0:
+ resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
+
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
- shadcn@4.1.0:
- resolution: {integrity: sha512-3zETJ+0Ezj69FS6RL0HOkLKKAR5yXisXx1iISJdfLQfrUqj/VIQlanQi1Ukk+9OE+XHZVj4FQNTBSfbr2CyCYg==}
+ shadcn@4.3.0:
+ resolution: {integrity: sha512-7vhnBh2LVLyxOd1ZQWwXv7OATCnQcxdqc8FbZdNigZriNOwDsHklQmPpvPt1jcrFK5mzMI+cyuAYv8WzERx2Og==}
hasBin: true
shebang-command@2.0.0:
@@ -3532,8 +3599,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
- side-channel-list@1.0.0:
- resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
+ side-channel-list@1.0.1:
+ resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
side-channel-map@1.0.1:
@@ -3629,20 +3696,12 @@ packages:
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
engines: {node: '>=18'}
- strip-json-comments@3.1.1:
- resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
- engines: {node: '>=8'}
-
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
style-to-object@1.0.14:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
- supports-color@7.2.0:
- resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
- engines: {node: '>=8'}
-
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
@@ -3650,28 +3709,25 @@ packages:
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
- tailwindcss@4.2.2:
- resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
+ tailwindcss@4.2.4:
+ resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==}
- tapable@2.3.0:
- resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
+ tapable@2.3.3:
+ resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
- tiny-warning@1.0.3:
- resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
-
- tinyglobby@0.2.15:
- resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
+ tinyglobby@0.2.16:
+ resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
- tldts-core@7.0.26:
- resolution: {integrity: sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==}
+ tldts-core@7.0.28:
+ resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==}
- tldts@7.0.26:
- resolution: {integrity: sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==}
+ tldts@7.0.28:
+ resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==}
hasBin: true
to-regex-range@5.0.1:
@@ -3728,20 +3784,20 @@ packages:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
- typescript-eslint@8.57.1:
- resolution: {integrity: sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==}
+ typescript-eslint@8.59.1:
+ resolution: {integrity: sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
- typescript: '>=4.8.4 <6.0.0'
+ typescript: '>=4.8.4 <6.1.0'
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
- undici-types@7.18.2:
- resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
+ undici-types@7.19.2:
+ resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
@@ -3750,6 +3806,9 @@ packages:
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
+ unist-util-find-after@5.0.0:
+ resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
+
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
@@ -3861,15 +3920,16 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
- vite@7.3.1:
- resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
+ vite@8.0.10:
+ resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
+ '@vitejs/devtools': ^0.1.0
+ esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
- lightningcss: ^1.21.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
@@ -3880,12 +3940,14 @@ packages:
peerDependenciesMeta:
'@types/node':
optional: true
+ '@vitejs/devtools':
+ optional: true
+ esbuild:
+ optional: true
jiti:
optional: true
less:
optional: true
- lightningcss:
- optional: true
sass:
optional: true
sass-embedded:
@@ -3933,10 +3995,6 @@ packages:
resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==}
engines: {node: '>=20'}
- wrap-ansi@6.2.0:
- resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
- engines: {node: '>=8'}
-
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -3967,18 +4025,18 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
- yoctocolors-cjs@2.1.3:
- resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
- engines: {node: '>=18'}
+ yocto-spinner@1.1.0:
+ resolution: {integrity: sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==}
+ engines: {node: '>=18.19'}
yoctocolors@2.1.2:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
- zod-to-json-schema@3.25.1:
- resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
+ zod-to-json-schema@3.25.2:
+ resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
peerDependencies:
- zod: ^3.25 || ^4
+ zod: ^3.25.28 || ^4
zod-validation-error@4.0.2:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
@@ -4041,7 +4099,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.29.0
'@babel/helper-validator-option': 7.27.1
- browserslist: 4.28.1
+ browserslist: 4.28.2
lru-cache: 5.1.1
semver: 6.3.1
@@ -4138,16 +4196,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@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/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
@@ -4195,22 +4243,39 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
- '@dotenvx/dotenvx@1.57.0':
+ '@dotenvx/dotenvx@1.61.0':
dependencies:
commander: 11.1.0
- dotenv: 17.3.1
+ dotenv: 17.4.2
eciesjs: 0.4.18
execa: 5.1.1
- fdir: 6.5.0(picomatch@4.0.3)
+ fdir: 6.5.0(picomatch@4.0.4)
ignore: 5.3.2
object-treeify: 1.1.33
- picomatch: 4.0.3
+ picomatch: 4.0.4
which: 4.0.0
+ yocto-spinner: 1.1.0
- '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)':
+ '@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)':
dependencies:
'@noble/ciphers': 1.3.0
+ '@emnapi/core@1.10.0':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.1
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/runtime@1.10.0':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/wasi-threads@1.2.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@esbuild/aix-ppc64@0.27.4':
optional: true
@@ -4289,50 +4354,38 @@ snapshots:
'@esbuild/win32-x64@0.27.4':
optional: true
- '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
+ '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1(jiti@2.7.0))':
dependencies:
- eslint: 9.39.4(jiti@2.6.1)
+ eslint: 10.2.1(jiti@2.7.0)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
- '@eslint/config-array@0.21.2':
+ '@eslint/config-array@0.23.5':
dependencies:
- '@eslint/object-schema': 2.1.7
+ '@eslint/object-schema': 3.0.5
debug: 4.4.3
- minimatch: 3.1.5
+ minimatch: 10.2.5
transitivePeerDependencies:
- supports-color
- '@eslint/config-helpers@0.4.2':
+ '@eslint/config-helpers@0.5.5':
dependencies:
- '@eslint/core': 0.17.0
+ '@eslint/core': 1.2.1
- '@eslint/core@0.17.0':
+ '@eslint/core@1.2.1':
dependencies:
'@types/json-schema': 7.0.15
- '@eslint/eslintrc@3.3.5':
+ '@eslint/js@10.0.1(eslint@10.2.1(jiti@2.7.0))':
+ optionalDependencies:
+ eslint: 10.2.1(jiti@2.7.0)
+
+ '@eslint/object-schema@3.0.5': {}
+
+ '@eslint/plugin-kit@0.7.1':
dependencies:
- ajv: 6.14.0
- debug: 4.4.3
- espree: 10.4.0
- globals: 14.0.0
- ignore: 5.3.2
- import-fresh: 3.3.1
- js-yaml: 4.1.1
- minimatch: 3.1.5
- strip-json-comments: 3.1.1
- transitivePeerDependencies:
- - supports-color
-
- '@eslint/js@9.39.4': {}
-
- '@eslint/object-schema@2.1.7': {}
-
- '@eslint/plugin-kit@0.4.1':
- dependencies:
- '@eslint/core': 0.17.0
+ '@eslint/core': 1.2.1
levn: 0.4.1
'@floating-ui/core@1.7.5':
@@ -4344,19 +4397,19 @@ snapshots:
'@floating-ui/core': 1.7.5
'@floating-ui/utils': 0.2.11
- '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@floating-ui/react-dom@2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@floating-ui/dom': 1.7.6
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
'@floating-ui/utils@0.2.11': {}
'@fontsource-variable/inter@5.2.8': {}
- '@hono/node-server@1.19.11(hono@4.12.8)':
+ '@hono/node-server@1.19.14(hono@4.12.14)':
dependencies:
- hono: 4.12.8
+ hono: 4.12.14
'@humanfs/core@0.19.1': {}
@@ -4369,33 +4422,32 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
- '@inquirer/ansi@1.0.2': {}
+ '@inquirer/ansi@2.0.5': {}
- '@inquirer/confirm@5.1.21(@types/node@25.5.0)':
+ '@inquirer/confirm@6.0.11(@types/node@25.6.0)':
dependencies:
- '@inquirer/core': 10.3.2(@types/node@25.5.0)
- '@inquirer/type': 3.0.10(@types/node@25.5.0)
+ '@inquirer/core': 11.1.8(@types/node@25.6.0)
+ '@inquirer/type': 4.0.5(@types/node@25.6.0)
optionalDependencies:
- '@types/node': 25.5.0
+ '@types/node': 25.6.0
- '@inquirer/core@10.3.2(@types/node@25.5.0)':
+ '@inquirer/core@11.1.8(@types/node@25.6.0)':
dependencies:
- '@inquirer/ansi': 1.0.2
- '@inquirer/figures': 1.0.15
- '@inquirer/type': 3.0.10(@types/node@25.5.0)
+ '@inquirer/ansi': 2.0.5
+ '@inquirer/figures': 2.0.5
+ '@inquirer/type': 4.0.5(@types/node@25.6.0)
cli-width: 4.1.0
- mute-stream: 2.0.0
+ fast-wrap-ansi: 0.2.0
+ mute-stream: 3.0.0
signal-exit: 4.1.0
- wrap-ansi: 6.2.0
- yoctocolors-cjs: 2.1.3
optionalDependencies:
- '@types/node': 25.5.0
+ '@types/node': 25.6.0
- '@inquirer/figures@1.0.15': {}
+ '@inquirer/figures@2.0.5': {}
- '@inquirer/type@3.0.10(@types/node@25.5.0)':
+ '@inquirer/type@4.0.5(@types/node@25.6.0)':
optionalDependencies:
- '@types/node': 25.5.0
+ '@types/node': 25.6.0
'@jridgewell/gen-mapping@0.3.13':
dependencies:
@@ -4416,9 +4468,9 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
- '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)':
+ '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
dependencies:
- '@hono/node-server': 1.19.11(hono@4.12.8)
+ '@hono/node-server': 1.19.14(hono@4.12.14)
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
content-type: 1.0.5
@@ -4427,14 +4479,14 @@ snapshots:
eventsource: 3.0.7
eventsource-parser: 3.0.6
express: 5.2.1
- express-rate-limit: 8.3.1(express@5.2.1)
- hono: 4.12.8
+ express-rate-limit: 8.3.2(express@5.2.1)
+ hono: 4.12.14
jose: 6.2.2
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
zod: 3.25.76
- zod-to-json-schema: 3.25.1(zod@3.25.76)
+ zod-to-json-schema: 3.25.2(zod@3.25.76)
transitivePeerDependencies:
- supports-color
@@ -4447,6 +4499,13 @@ snapshots:
outvariant: 1.4.3
strict-event-emitter: 0.5.1
+ '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@tybys/wasm-util': 0.10.1
+ optional: true
+
'@noble/ciphers@1.3.0': {}
'@noble/curves@1.9.7':
@@ -4469,6 +4528,8 @@ snapshots:
'@open-draft/deferred-promise@2.2.0': {}
+ '@open-draft/deferred-promise@3.0.0': {}
+
'@open-draft/logger@0.3.0':
dependencies:
is-node-process: 1.2.0
@@ -4476,977 +4537,956 @@ snapshots:
'@open-draft/until@2.1.0': {}
+ '@oxc-project/types@0.127.0': {}
+
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
- '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
aria-hidden: 1.2.6
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
aria-hidden: 1.2.6
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
aria-hidden: 1.2.6
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/rect': 1.1.1
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
aria-hidden: 1.2.6
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
- use-sync-external-store: 1.6.0(react@19.2.4)
+ react: 19.2.5
+ use-sync-external-store: 1.6.0(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
'@radix-ui/rect': 1.1.1
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/rect@1.1.1': {}
- '@rolldown/pluginutils@1.0.0-rc.3': {}
-
- '@rollup/rollup-android-arm-eabi@4.59.0':
+ '@rolldown/binding-android-arm64@1.0.0-rc.17':
optional: true
- '@rollup/rollup-android-arm64@4.59.0':
+ '@rolldown/binding-darwin-arm64@1.0.0-rc.17':
optional: true
- '@rollup/rollup-darwin-arm64@4.59.0':
+ '@rolldown/binding-darwin-x64@1.0.0-rc.17':
optional: true
- '@rollup/rollup-darwin-x64@4.59.0':
+ '@rolldown/binding-freebsd-x64@1.0.0-rc.17':
optional: true
- '@rollup/rollup-freebsd-arm64@4.59.0':
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':
optional: true
- '@rollup/rollup-freebsd-x64@4.59.0':
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-arm-gnueabihf@4.59.0':
+ '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-arm-musleabihf@4.59.0':
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-arm64-gnu@4.59.0':
+ '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-arm64-musl@4.59.0':
+ '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-loong64-gnu@4.59.0':
+ '@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-loong64-musl@4.59.0':
+ '@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-ppc64-gnu@4.59.0':
+ '@rolldown/binding-wasm32-wasi@1.0.0-rc.17':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
- '@rollup/rollup-linux-ppc64-musl@4.59.0':
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-riscv64-gnu@4.59.0':
+ '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':
optional: true
- '@rollup/rollup-linux-riscv64-musl@4.59.0':
- optional: true
+ '@rolldown/pluginutils@1.0.0-rc.17': {}
- '@rollup/rollup-linux-s390x-gnu@4.59.0':
- optional: true
-
- '@rollup/rollup-linux-x64-gnu@4.59.0':
- optional: true
-
- '@rollup/rollup-linux-x64-musl@4.59.0':
- optional: true
-
- '@rollup/rollup-openbsd-x64@4.59.0':
- optional: true
-
- '@rollup/rollup-openharmony-arm64@4.59.0':
- optional: true
-
- '@rollup/rollup-win32-arm64-msvc@4.59.0':
- optional: true
-
- '@rollup/rollup-win32-ia32-msvc@4.59.0':
- optional: true
-
- '@rollup/rollup-win32-x64-gnu@4.59.0':
- optional: true
-
- '@rollup/rollup-win32-x64-msvc@4.59.0':
- optional: true
+ '@rolldown/pluginutils@1.0.0-rc.7': {}
'@sec-ant/readable-stream@0.4.1': {}
'@sindresorhus/merge-streams@4.0.0': {}
- '@tabler/icons-react@3.40.0(react@19.2.4)':
+ '@tabler/icons-react@3.41.1(react@19.2.5)':
dependencies:
- '@tabler/icons': 3.40.0
- react: 19.2.4
+ '@tabler/icons': 3.41.1
+ react: 19.2.5
- '@tabler/icons@3.40.0': {}
+ '@tabler/icons@3.41.1': {}
- '@tailwindcss/node@4.2.2':
+ '@tailwindcss/node@4.2.4':
dependencies:
'@jridgewell/remapping': 2.3.5
- enhanced-resolve: 5.20.1
- jiti: 2.6.1
+ enhanced-resolve: 5.21.0
+ jiti: 2.7.0
lightningcss: 1.32.0
magic-string: 0.30.21
source-map-js: 1.2.1
- tailwindcss: 4.2.2
+ tailwindcss: 4.2.4
- '@tailwindcss/oxide-android-arm64@4.2.2':
+ '@tailwindcss/oxide-android-arm64@4.2.4':
optional: true
- '@tailwindcss/oxide-darwin-arm64@4.2.2':
+ '@tailwindcss/oxide-darwin-arm64@4.2.4':
optional: true
- '@tailwindcss/oxide-darwin-x64@4.2.2':
+ '@tailwindcss/oxide-darwin-x64@4.2.4':
optional: true
- '@tailwindcss/oxide-freebsd-x64@4.2.2':
+ '@tailwindcss/oxide-freebsd-x64@4.2.4':
optional: true
- '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4':
optional: true
- '@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.4':
optional: true
- '@tailwindcss/oxide-linux-arm64-musl@4.2.2':
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.4':
optional: true
- '@tailwindcss/oxide-linux-x64-gnu@4.2.2':
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.4':
optional: true
- '@tailwindcss/oxide-linux-x64-musl@4.2.2':
+ '@tailwindcss/oxide-linux-x64-musl@4.2.4':
optional: true
- '@tailwindcss/oxide-wasm32-wasi@4.2.2':
+ '@tailwindcss/oxide-wasm32-wasi@4.2.4':
optional: true
- '@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.4':
optional: true
- '@tailwindcss/oxide-win32-x64-msvc@4.2.2':
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.4':
optional: true
- '@tailwindcss/oxide@4.2.2':
+ '@tailwindcss/oxide@4.2.4':
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/oxide-android-arm64': 4.2.4
+ '@tailwindcss/oxide-darwin-arm64': 4.2.4
+ '@tailwindcss/oxide-darwin-x64': 4.2.4
+ '@tailwindcss/oxide-freebsd-x64': 4.2.4
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4
+ '@tailwindcss/oxide-linux-arm64-musl': 4.2.4
+ '@tailwindcss/oxide-linux-x64-gnu': 4.2.4
+ '@tailwindcss/oxide-linux-x64-musl': 4.2.4
+ '@tailwindcss/oxide-wasm32-wasi': 4.2.4
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4
+ '@tailwindcss/oxide-win32-x64-msvc': 4.2.4
- '@tailwindcss/typography@0.5.19(tailwindcss@4.2.2)':
+ '@tailwindcss/typography@0.5.19(tailwindcss@4.2.4)':
dependencies:
postcss-selector-parser: 6.0.10
- tailwindcss: 4.2.2
+ tailwindcss: 4.2.4
- '@tailwindcss/vite@4.2.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))':
+ '@tailwindcss/vite@4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))':
dependencies:
- '@tailwindcss/node': 4.2.2
- '@tailwindcss/oxide': 4.2.2
- tailwindcss: 4.2.2
- vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
+ '@tailwindcss/node': 4.2.4
+ '@tailwindcss/oxide': 4.2.4
+ tailwindcss: 4.2.4
+ vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)
'@tanstack/history@1.161.6': {}
- '@tanstack/query-core@5.91.2': {}
+ '@tanstack/query-core@5.99.0': {}
- '@tanstack/react-query@5.91.2(react@19.2.4)':
+ '@tanstack/react-query@5.99.0(react@19.2.5)':
dependencies:
- '@tanstack/query-core': 5.91.2
- react: 19.2.4
+ '@tanstack/query-core': 5.99.0
+ react: 19.2.5
- '@tanstack/react-router-devtools@1.166.9(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.5)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@tanstack/react-router-devtools@1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@tanstack/react-router': 1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@tanstack/router-devtools-core': 1.166.9(@tanstack/router-core@1.167.5)(csstype@3.2.3)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@tanstack/react-router': 1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@tanstack/router-devtools-core': 1.167.3(@tanstack/router-core@1.169.2)(csstype@3.2.3)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
- '@tanstack/router-core': 1.167.5
+ '@tanstack/router-core': 1.169.2
transitivePeerDependencies:
- csstype
- '@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@tanstack/history': 1.161.6
- '@tanstack/react-store': 0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@tanstack/router-core': 1.167.5
- isbot: 5.1.36
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- tiny-invariant: 1.3.3
- tiny-warning: 1.0.3
+ '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@tanstack/router-core': 1.169.2
+ isbot: 5.1.40
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
- '@tanstack/react-store@0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@tanstack/store': 0.9.2
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- use-sync-external-store: 1.6.0(react@19.2.4)
+ '@tanstack/store': 0.9.3
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ use-sync-external-store: 1.6.0(react@19.2.5)
- '@tanstack/router-core@1.167.5':
+ '@tanstack/router-core@1.168.7':
dependencies:
'@tanstack/history': 1.161.6
- '@tanstack/store': 0.9.2
cookie-es: 2.0.0
seroval: 1.5.1
seroval-plugins: 1.5.1(seroval@1.5.1)
- tiny-invariant: 1.3.3
- tiny-warning: 1.0.3
- '@tanstack/router-devtools-core@1.166.9(@tanstack/router-core@1.167.5)(csstype@3.2.3)':
+ '@tanstack/router-core@1.169.2':
dependencies:
- '@tanstack/router-core': 1.167.5
+ '@tanstack/history': 1.161.6
+ cookie-es: 3.1.1
+ seroval: 1.5.4
+ seroval-plugins: 1.5.4(seroval@1.5.4)
+
+ '@tanstack/router-devtools-core@1.167.3(@tanstack/router-core@1.169.2)(csstype@3.2.3)':
+ dependencies:
+ '@tanstack/router-core': 1.169.2
clsx: 2.1.1
goober: 2.1.18(csstype@3.2.3)
- tiny-invariant: 1.3.3
optionalDependencies:
csstype: 3.2.3
- '@tanstack/router-generator@1.166.13':
+ '@tanstack/router-generator@1.166.22':
dependencies:
- '@tanstack/router-core': 1.167.5
+ '@tanstack/router-core': 1.168.7
'@tanstack/router-utils': 1.161.6
'@tanstack/virtual-file-routes': 1.161.7
- prettier: 3.8.1
+ prettier: 3.8.3
recast: 0.23.11
source-map: 0.7.6
tsx: 4.21.0
@@ -5454,7 +5494,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@tanstack/router-plugin@1.166.14(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))':
+ '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
@@ -5462,16 +5502,16 @@ snapshots:
'@babel/template': 7.28.6
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
- '@tanstack/router-core': 1.167.5
- '@tanstack/router-generator': 1.166.13
+ '@tanstack/router-core': 1.168.7
+ '@tanstack/router-generator': 1.166.22
'@tanstack/router-utils': 1.161.6
'@tanstack/virtual-file-routes': 1.161.7
chokidar: 3.6.0
unplugin: 2.3.11
zod: 3.25.76
optionalDependencies:
- '@tanstack/react-router': 1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
+ '@tanstack/react-router': 1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)
transitivePeerDependencies:
- supports-color
@@ -5483,17 +5523,17 @@ snapshots:
'@babel/types': 7.29.0
ansis: 4.2.0
babel-dead-code-elimination: 1.0.12
- diff: 8.0.3
+ diff: 8.0.4
pathe: 2.0.3
- tinyglobby: 0.2.15
+ tinyglobby: 0.2.16
transitivePeerDependencies:
- supports-color
- '@tanstack/store@0.9.2': {}
+ '@tanstack/store@0.9.3': {}
'@tanstack/virtual-file-routes@1.161.7': {}
- '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1)':
+ '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3)':
dependencies:
'@babel/generator': 7.29.1
'@babel/parser': 7.29.2
@@ -5503,41 +5543,27 @@ snapshots:
lodash-es: 4.17.23
minimatch: 9.0.9
parse-imports-exports: 0.2.4
- prettier: 3.8.1
+ prettier: 3.8.3
transitivePeerDependencies:
- supports-color
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3
- minimatch: 10.2.4
+ minimatch: 10.2.5
path-browserify: 1.0.1
- '@types/babel__core@7.20.5':
+ '@tybys/wasm-util@0.10.1':
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
+ tslib: 2.8.1
+ optional: true
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
+ '@types/esrecurse@4.3.1': {}
+
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
@@ -5556,9 +5582,9 @@ snapshots:
'@types/ms@2.1.0': {}
- '@types/node@25.5.0':
+ '@types/node@25.6.0':
dependencies:
- undici-types: 7.18.2
+ undici-types: 7.19.2
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
@@ -5568,6 +5594,10 @@ snapshots:
dependencies:
csstype: 3.2.3
+ '@types/set-cookie-parser@2.4.10':
+ dependencies:
+ '@types/node': 25.6.0
+
'@types/statuses@2.0.6': {}
'@types/unist@2.0.11': {}
@@ -5576,15 +5606,15 @@ snapshots:
'@types/validate-npm-package-name@4.0.2': {}
- '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/scope-manager': 8.57.1
- '@typescript-eslint/type-utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.57.1
- eslint: 9.39.4(jiti@2.6.1)
+ '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.58.2
+ '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.58.2
+ eslint: 10.2.1(jiti@2.7.0)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -5592,94 +5622,166 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/scope-manager': 8.57.1
- '@typescript-eslint/types': 8.57.1
- '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.57.1
- debug: 4.4.3
- eslint: 9.39.4(jiti@2.6.1)
- typescript: 5.9.3
- transitivePeerDependencies:
- - supports-color
-
- '@typescript-eslint/project-service@8.57.1(typescript@5.9.3)':
- dependencies:
- '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3)
- '@typescript-eslint/types': 8.57.1
- debug: 4.4.3
- typescript: 5.9.3
- transitivePeerDependencies:
- - supports-color
-
- '@typescript-eslint/scope-manager@8.57.1':
- dependencies:
- '@typescript-eslint/types': 8.57.1
- '@typescript-eslint/visitor-keys': 8.57.1
-
- '@typescript-eslint/tsconfig-utils@8.57.1(typescript@5.9.3)':
- dependencies:
- typescript: 5.9.3
-
- '@typescript-eslint/type-utils@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
- dependencies:
- '@typescript-eslint/types': 8.57.1
- '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3)
- '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- debug: 4.4.3
- eslint: 9.39.4(jiti@2.6.1)
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.59.1
+ '@typescript-eslint/type-utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.59.1
+ eslint: 10.2.1(jiti@2.7.0)
+ ignore: 7.0.5
+ natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/types@8.57.1': {}
-
- '@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3)':
+ '@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/project-service': 8.57.1(typescript@5.9.3)
- '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3)
- '@typescript-eslint/types': 8.57.1
- '@typescript-eslint/visitor-keys': 8.57.1
+ '@typescript-eslint/scope-manager': 8.59.1
+ '@typescript-eslint/types': 8.59.1
+ '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.59.1
debug: 4.4.3
- minimatch: 10.2.4
+ eslint: 10.2.1(jiti@2.7.0)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.58.2(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.59.1
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.59.1(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.59.1
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/scope-manager@8.58.2':
+ dependencies:
+ '@typescript-eslint/types': 8.58.2
+ '@typescript-eslint/visitor-keys': 8.58.2
+
+ '@typescript-eslint/scope-manager@8.59.1':
+ dependencies:
+ '@typescript-eslint/types': 8.59.1
+ '@typescript-eslint/visitor-keys': 8.59.1
+
+ '@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@typescript-eslint/tsconfig-utils@8.59.1(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@typescript-eslint/type-utils@8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.58.2
+ '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 10.2.1(jiti@2.7.0)
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/type-utils@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.59.1
+ '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 10.2.1(jiti@2.7.0)
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/types@8.58.2': {}
+
+ '@typescript-eslint/types@8.59.1': {}
+
+ '@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.58.2(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3)
+ '@typescript-eslint/types': 8.58.2
+ '@typescript-eslint/visitor-keys': 8.58.2
+ debug: 4.4.3
+ minimatch: 10.2.5
semver: 7.7.4
- tinyglobby: 0.2.15
+ tinyglobby: 0.2.16
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/typescript-estree@8.59.1(typescript@5.9.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
- '@typescript-eslint/scope-manager': 8.57.1
- '@typescript-eslint/types': 8.57.1
- '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3)
- eslint: 9.39.4(jiti@2.6.1)
+ '@typescript-eslint/project-service': 8.59.1(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.59.1
+ '@typescript-eslint/visitor-keys': 8.59.1
+ debug: 4.4.3
+ minimatch: 10.2.5
+ semver: 7.7.4
+ tinyglobby: 0.2.16
+ ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/visitor-keys@8.57.1':
+ '@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/types': 8.57.1
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0))
+ '@typescript-eslint/scope-manager': 8.58.2
+ '@typescript-eslint/types': 8.58.2
+ '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3)
+ eslint: 10.2.1(jiti@2.7.0)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/utils@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0))
+ '@typescript-eslint/scope-manager': 8.59.1
+ '@typescript-eslint/types': 8.59.1
+ '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3)
+ eslint: 10.2.1(jiti@2.7.0)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/visitor-keys@8.58.2':
+ dependencies:
+ '@typescript-eslint/types': 8.58.2
+ eslint-visitor-keys: 5.0.1
+
+ '@typescript-eslint/visitor-keys@8.59.1':
+ dependencies:
+ '@typescript-eslint/types': 8.59.1
eslint-visitor-keys: 5.0.1
'@ungap/structured-clone@1.3.0': {}
- '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))':
+ '@vitejs/plugin-react@6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.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-rc.3
- '@types/babel__core': 7.20.5
- react-refresh: 0.18.0
- vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
- transitivePeerDependencies:
- - supports-color
+ '@rolldown/pluginutils': 1.0.0-rc.7
+ vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)
accepts@2.0.0:
dependencies:
@@ -5727,7 +5829,7 @@ snapshots:
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
- picomatch: 2.3.1
+ picomatch: 2.3.2
argparse@2.0.1: {}
@@ -5754,7 +5856,7 @@ snapshots:
balanced-match@4.0.4: {}
- baseline-browser-mapping@2.10.9: {}
+ baseline-browser-mapping@2.10.17: {}
binary-extensions@2.3.0: {}
@@ -5766,22 +5868,17 @@ snapshots:
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
- qs: 6.15.0
+ qs: 6.15.1
raw-body: 3.0.2
type-is: 2.0.1
transitivePeerDependencies:
- supports-color
- brace-expansion@1.1.12:
- dependencies:
- balanced-match: 1.0.2
- concat-map: 0.0.1
-
- brace-expansion@2.0.2:
+ brace-expansion@2.0.3:
dependencies:
balanced-match: 1.0.2
- brace-expansion@5.0.4:
+ brace-expansion@5.0.5:
dependencies:
balanced-match: 4.0.4
@@ -5789,13 +5886,13 @@ snapshots:
dependencies:
fill-range: 7.1.1
- browserslist@4.28.1:
+ browserslist@4.28.2:
dependencies:
- baseline-browser-mapping: 2.10.9
- caniuse-lite: 1.0.30001780
- electron-to-chromium: 1.5.321
- node-releases: 2.0.36
- update-browserslist-db: 1.2.3(browserslist@4.28.1)
+ baseline-browser-mapping: 2.10.17
+ caniuse-lite: 1.0.30001787
+ electron-to-chromium: 1.5.334
+ node-releases: 2.0.37
+ update-browserslist-db: 1.2.3(browserslist@4.28.2)
bundle-name@4.1.0:
dependencies:
@@ -5815,15 +5912,10 @@ snapshots:
callsites@3.1.0: {}
- caniuse-lite@1.0.30001780: {}
+ caniuse-lite@1.0.30001787: {}
ccount@2.0.1: {}
- chalk@4.1.2:
- dependencies:
- ansi-styles: 4.3.0
- supports-color: 7.2.0
-
chalk@5.6.2: {}
character-entities-html4@2.1.0: {}
@@ -5880,9 +5972,7 @@ snapshots:
commander@14.0.3: {}
- concat-map@0.0.1: {}
-
- content-disposition@1.0.1: {}
+ content-disposition@1.1.0: {}
content-type@1.0.5: {}
@@ -5890,6 +5980,8 @@ snapshots:
cookie-es@2.0.0: {}
+ cookie-es@3.1.1: {}
+
cookie-signature@1.2.2: {}
cookie@0.7.2: {}
@@ -5959,9 +6051,9 @@ snapshots:
dependencies:
dequal: 2.0.3
- diff@8.0.3: {}
+ diff@8.0.4: {}
- dotenv@17.3.1: {}
+ dotenv@17.4.2: {}
dunder-proto@1.0.1:
dependencies:
@@ -5971,14 +6063,14 @@ snapshots:
eciesjs@0.4.18:
dependencies:
- '@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0)
+ '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0)
'@noble/ciphers': 1.3.0
'@noble/curves': 1.9.7
'@noble/hashes': 1.8.0
ee-first@1.1.1: {}
- electron-to-chromium@1.5.321: {}
+ electron-to-chromium@1.5.334: {}
emoji-regex@10.6.0: {}
@@ -5986,10 +6078,10 @@ snapshots:
encodeurl@2.0.0: {}
- enhanced-resolve@5.20.1:
+ enhanced-resolve@5.21.0:
dependencies:
graceful-fs: 4.2.11
- tapable: 2.3.0
+ tapable: 2.3.3
entities@6.0.1: {}
@@ -6044,58 +6136,55 @@ snapshots:
escape-string-regexp@5.0.0: {}
- eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)):
+ eslint-config-prettier@10.1.8(eslint@10.2.1(jiti@2.7.0)):
dependencies:
- eslint: 9.39.4(jiti@2.6.1)
+ eslint: 10.2.1(jiti@2.7.0)
- eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)):
+ eslint-plugin-react-hooks@7.1.1(eslint@10.2.1(jiti@2.7.0)):
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.2
- eslint: 9.39.4(jiti@2.6.1)
+ eslint: 10.2.1(jiti@2.7.0)
hermes-parser: 0.25.1
zod: 4.3.6
zod-validation-error: 4.0.2(zod@4.3.6)
transitivePeerDependencies:
- supports-color
- eslint-plugin-react-refresh@0.4.26(eslint@9.39.4(jiti@2.6.1)):
+ eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.7.0)):
dependencies:
- eslint: 9.39.4(jiti@2.6.1)
+ eslint: 10.2.1(jiti@2.7.0)
- eslint-scope@8.4.0:
+ eslint-scope@9.1.2:
dependencies:
+ '@types/esrecurse': 4.3.1
+ '@types/estree': 1.0.8
esrecurse: 4.3.0
estraverse: 5.3.0
eslint-visitor-keys@3.4.3: {}
- eslint-visitor-keys@4.2.1: {}
-
eslint-visitor-keys@5.0.1: {}
- eslint@9.39.4(jiti@2.6.1):
+ eslint@10.2.1(jiti@2.7.0):
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0))
'@eslint-community/regexpp': 4.12.2
- '@eslint/config-array': 0.21.2
- '@eslint/config-helpers': 0.4.2
- '@eslint/core': 0.17.0
- '@eslint/eslintrc': 3.3.5
- '@eslint/js': 9.39.4
- '@eslint/plugin-kit': 0.4.1
+ '@eslint/config-array': 0.23.5
+ '@eslint/config-helpers': 0.5.5
+ '@eslint/core': 1.2.1
+ '@eslint/plugin-kit': 0.7.1
'@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
ajv: 6.14.0
- chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
escape-string-regexp: 4.0.0
- eslint-scope: 8.4.0
- eslint-visitor-keys: 4.2.1
- espree: 10.4.0
+ eslint-scope: 9.1.2
+ eslint-visitor-keys: 5.0.1
+ espree: 11.2.0
esquery: 1.7.0
esutils: 2.0.3
fast-deep-equal: 3.1.3
@@ -6106,20 +6195,19 @@ snapshots:
imurmurhash: 0.1.4
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
- lodash.merge: 4.6.2
- minimatch: 3.1.5
+ minimatch: 10.2.5
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
- jiti: 2.6.1
+ jiti: 2.7.0
transitivePeerDependencies:
- supports-color
- espree@10.4.0:
+ espree@11.2.0:
dependencies:
acorn: 8.16.0
acorn-jsx: 5.3.2(acorn@8.16.0)
- eslint-visitor-keys: 4.2.1
+ eslint-visitor-keys: 5.0.1
esprima@4.0.1: {}
@@ -6172,7 +6260,7 @@ snapshots:
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
- express-rate-limit@8.3.1(express@5.2.1):
+ express-rate-limit@8.3.2(express@5.2.1):
dependencies:
express: 5.2.1
ip-address: 10.1.0
@@ -6181,7 +6269,7 @@ snapshots:
dependencies:
accepts: 2.0.0
body-parser: 2.2.2
- content-disposition: 1.0.1
+ content-disposition: 1.1.0
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
@@ -6199,7 +6287,7 @@ snapshots:
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
- qs: 6.15.0
+ qs: 6.15.1
range-parser: 1.2.1
router: 2.2.0
send: 1.2.1
@@ -6226,15 +6314,25 @@ snapshots:
fast-levenshtein@2.0.6: {}
+ fast-string-truncated-width@3.0.3: {}
+
+ fast-string-width@3.0.2:
+ dependencies:
+ fast-string-truncated-width: 3.0.3
+
fast-uri@3.1.0: {}
+ fast-wrap-ansi@0.2.0:
+ dependencies:
+ fast-string-width: 3.0.2
+
fastq@1.20.1:
dependencies:
reusify: 1.1.0
- fdir@6.5.0(picomatch@4.0.3):
+ fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
- picomatch: 4.0.3
+ picomatch: 4.0.4
fetch-blob@3.2.0:
dependencies:
@@ -6332,7 +6430,7 @@ snapshots:
'@sec-ant/readable-stream': 0.4.1
is-stream: 4.0.1
- get-tsconfig@4.13.6:
+ get-tsconfig@4.13.7:
dependencies:
resolve-pkg-maps: 1.0.0
@@ -6344,9 +6442,7 @@ snapshots:
dependencies:
is-glob: 4.0.3
- globals@14.0.0: {}
-
- globals@16.5.0: {}
+ globals@17.5.0: {}
goober@2.1.18(csstype@3.2.3):
dependencies:
@@ -6356,9 +6452,7 @@ snapshots:
graceful-fs@4.2.11: {}
- graphql@16.13.1: {}
-
- has-flag@4.0.0: {}
+ graphql@16.13.2: {}
has-symbols@1.1.0: {}
@@ -6377,6 +6471,10 @@ snapshots:
vfile-location: 5.0.3
web-namespaces: 2.0.1
+ hast-util-is-element@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
hast-util-parse-selector@4.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -6433,6 +6531,13 @@ snapshots:
web-namespaces: 2.0.1
zwitch: 2.0.4
+ hast-util-to-text@4.0.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ hast-util-is-element: 3.0.0
+ unist-util-find-after: 5.0.0
+
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -6445,7 +6550,10 @@ snapshots:
property-information: 7.1.0
space-separated-tokens: 2.0.2
- headers-polyfill@4.0.3: {}
+ headers-polyfill@5.0.1:
+ dependencies:
+ '@types/set-cookie-parser': 2.4.10
+ set-cookie-parser: 3.1.0
hermes-estree@0.25.1: {}
@@ -6453,7 +6561,9 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
- hono@4.12.8: {}
+ highlight.js@11.11.1: {}
+
+ hono@4.12.14: {}
html-parse-stringify@3.0.1:
dependencies:
@@ -6486,9 +6596,7 @@ snapshots:
dependencies:
'@babel/runtime': 7.29.2
- i18next@25.8.20(typescript@5.9.3):
- dependencies:
- '@babel/runtime': 7.29.2
+ i18next@26.0.8(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
@@ -6574,7 +6682,7 @@ snapshots:
dependencies:
is-inside-container: 1.0.0
- isbot@5.1.36: {}
+ isbot@5.1.40: {}
isexe@2.0.0: {}
@@ -6582,16 +6690,16 @@ snapshots:
javascript-natural-sort@0.7.1: {}
- jiti@2.6.1: {}
+ jiti@2.7.0: {}
jose@6.2.2: {}
- jotai@2.18.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4):
+ jotai@2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5):
optionalDependencies:
'@babel/core': 7.29.0
'@babel/template': 7.28.6
'@types/react': 19.2.14
- react: 19.2.4
+ react: 19.2.5
js-tokens@4.0.0: {}
@@ -6691,8 +6799,6 @@ snapshots:
lodash-es@4.17.23: {}
- lodash.merge@4.6.2: {}
-
log-symbols@6.0.0:
dependencies:
chalk: 5.6.2
@@ -6700,6 +6806,12 @@ snapshots:
longest-streak@3.1.0: {}
+ lowlight@3.3.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ devlop: 1.1.0
+ highlight.js: 11.11.1
+
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -7067,7 +7179,7 @@ snapshots:
micromatch@4.0.8:
dependencies:
braces: 3.0.3
- picomatch: 2.3.1
+ picomatch: 2.3.2
mime-db@1.54.0: {}
@@ -7079,36 +7191,32 @@ snapshots:
mimic-function@5.0.1: {}
- minimatch@10.2.4:
+ minimatch@10.2.5:
dependencies:
- brace-expansion: 5.0.4
-
- minimatch@3.1.5:
- dependencies:
- brace-expansion: 1.1.12
+ brace-expansion: 5.0.5
minimatch@9.0.9:
dependencies:
- brace-expansion: 2.0.2
+ brace-expansion: 2.0.3
minimist@1.2.8: {}
ms@2.1.3: {}
- msw@2.12.13(@types/node@25.5.0)(typescript@5.9.3):
+ msw@2.13.4(@types/node@25.6.0)(typescript@5.9.3):
dependencies:
- '@inquirer/confirm': 5.1.21(@types/node@25.5.0)
+ '@inquirer/confirm': 6.0.11(@types/node@25.6.0)
'@mswjs/interceptors': 0.41.3
- '@open-draft/deferred-promise': 2.2.0
+ '@open-draft/deferred-promise': 3.0.0
'@types/statuses': 2.0.6
cookie: 1.1.1
- graphql: 16.13.1
- headers-polyfill: 4.0.3
+ graphql: 16.13.2
+ headers-polyfill: 5.0.1
is-node-process: 1.2.0
outvariant: 1.4.3
path-to-regexp: 6.3.0
picocolors: 1.1.1
- rettime: 0.10.1
+ rettime: 0.11.7
statuses: 2.0.2
strict-event-emitter: 0.5.1
tough-cookie: 6.0.1
@@ -7120,7 +7228,7 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
- mute-stream@2.0.0: {}
+ mute-stream@3.0.0: {}
nanoid@3.3.11: {}
@@ -7136,7 +7244,7 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
- node-releases@2.0.36: {}
+ node-releases@2.0.37: {}
normalize-path@3.0.0: {}
@@ -7256,15 +7364,15 @@ snapshots:
path-to-regexp@6.3.0: {}
- path-to-regexp@8.3.0: {}
+ path-to-regexp@8.4.2: {}
pathe@2.0.3: {}
picocolors@1.1.1: {}
- picomatch@2.3.1: {}
+ picomatch@2.3.2: {}
- picomatch@4.0.3: {}
+ picomatch@4.0.4: {}
pkce-challenge@5.0.1: {}
@@ -7278,7 +7386,7 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
- postcss@8.5.8:
+ postcss@8.5.10:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
@@ -7288,13 +7396,13 @@ snapshots:
prelude-ls@1.2.1: {}
- prettier-plugin-tailwindcss@0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1))(prettier@3.8.1):
+ prettier-plugin-tailwindcss@0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3))(prettier@3.8.3):
dependencies:
- prettier: 3.8.1
+ prettier: 3.8.3
optionalDependencies:
- '@trivago/prettier-plugin-sort-imports': 6.0.2(prettier@3.8.1)
+ '@trivago/prettier-plugin-sort-imports': 6.0.2(prettier@3.8.3)
- prettier@3.8.1: {}
+ prettier@3.8.3: {}
pretty-ms@9.3.0:
dependencies:
@@ -7314,71 +7422,71 @@ snapshots:
punycode@2.3.1: {}
- qs@6.15.0:
+ qs@6.15.1:
dependencies:
side-channel: 1.1.0
queue-microtask@1.2.3: {}
- radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
@@ -7392,23 +7500,23 @@ snapshots:
iconv-lite: 0.7.2
unpipe: 1.0.0
- react-dom@19.2.4(react@19.2.4):
+ react-dom@19.2.5(react@19.2.5):
dependencies:
- react: 19.2.4
+ react: 19.2.5
scheduler: 0.27.0
- react-i18next@16.5.8(i18next@25.8.20(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
+ react-i18next@17.0.4(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.29.2
html-parse-stringify: 3.0.1
- i18next: 25.8.20(typescript@5.9.3)
- react: 19.2.4
- use-sync-external-store: 1.6.0(react@19.2.4)
+ i18next: 26.0.8(typescript@5.9.3)
+ react: 19.2.5
+ use-sync-external-store: 1.6.0(react@19.2.5)
optionalDependencies:
- react-dom: 19.2.4(react@19.2.4)
+ react-dom: 19.2.5(react@19.2.5)
typescript: 5.9.3
- react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4):
+ react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5):
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
@@ -7417,7 +7525,7 @@ snapshots:
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
+ react: 19.2.5
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
@@ -7426,49 +7534,47 @@ snapshots:
transitivePeerDependencies:
- supports-color
- react-refresh@0.18.0: {}
-
- react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4):
+ react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
- react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5)
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
- react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4):
+ react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
- react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4)
- react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.5)
+ react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5)
tslib: 2.8.1
- use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4)
- use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4)
+ use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.5)
+ use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
- react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4):
+ react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5):
dependencies:
get-nonce: 1.0.1
- react: 19.2.4
+ react: 19.2.5
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
- react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.4):
+ react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.5):
dependencies:
'@babel/runtime': 7.29.2
- react: 19.2.4
- use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.4)
- use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.5)
+ use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.5)
transitivePeerDependencies:
- '@types/react'
- react@19.2.4: {}
+ react@19.2.5: {}
readdirp@3.6.0:
dependencies:
- picomatch: 2.3.1
+ picomatch: 2.3.2
recast@0.23.11:
dependencies:
@@ -7478,6 +7584,14 @@ snapshots:
tiny-invariant: 1.3.3
tslib: 2.8.1
+ rehype-highlight@7.0.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-to-text: 4.0.2
+ lowlight: 3.3.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+
rehype-raw@7.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -7536,40 +7650,30 @@ snapshots:
onetime: 7.0.0
signal-exit: 4.1.0
- rettime@0.10.1: {}
+ rettime@0.11.7: {}
reusify@1.1.0: {}
- rollup@4.59.0:
+ rolldown@1.0.0-rc.17:
dependencies:
- '@types/estree': 1.0.8
+ '@oxc-project/types': 0.127.0
+ '@rolldown/pluginutils': 1.0.0-rc.17
optionalDependencies:
- '@rollup/rollup-android-arm-eabi': 4.59.0
- '@rollup/rollup-android-arm64': 4.59.0
- '@rollup/rollup-darwin-arm64': 4.59.0
- '@rollup/rollup-darwin-x64': 4.59.0
- '@rollup/rollup-freebsd-arm64': 4.59.0
- '@rollup/rollup-freebsd-x64': 4.59.0
- '@rollup/rollup-linux-arm-gnueabihf': 4.59.0
- '@rollup/rollup-linux-arm-musleabihf': 4.59.0
- '@rollup/rollup-linux-arm64-gnu': 4.59.0
- '@rollup/rollup-linux-arm64-musl': 4.59.0
- '@rollup/rollup-linux-loong64-gnu': 4.59.0
- '@rollup/rollup-linux-loong64-musl': 4.59.0
- '@rollup/rollup-linux-ppc64-gnu': 4.59.0
- '@rollup/rollup-linux-ppc64-musl': 4.59.0
- '@rollup/rollup-linux-riscv64-gnu': 4.59.0
- '@rollup/rollup-linux-riscv64-musl': 4.59.0
- '@rollup/rollup-linux-s390x-gnu': 4.59.0
- '@rollup/rollup-linux-x64-gnu': 4.59.0
- '@rollup/rollup-linux-x64-musl': 4.59.0
- '@rollup/rollup-openbsd-x64': 4.59.0
- '@rollup/rollup-openharmony-arm64': 4.59.0
- '@rollup/rollup-win32-arm64-msvc': 4.59.0
- '@rollup/rollup-win32-ia32-msvc': 4.59.0
- '@rollup/rollup-win32-x64-gnu': 4.59.0
- '@rollup/rollup-win32-x64-msvc': 4.59.0
- fsevents: 2.3.3
+ '@rolldown/binding-android-arm64': 1.0.0-rc.17
+ '@rolldown/binding-darwin-arm64': 1.0.0-rc.17
+ '@rolldown/binding-darwin-x64': 1.0.0-rc.17
+ '@rolldown/binding-freebsd-x64': 1.0.0-rc.17
+ '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17
+ '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17
+ '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17
+ '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17
+ '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17
+ '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17
+ '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17
+ '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17
+ '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17
+ '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17
+ '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17
router@2.2.0:
dependencies:
@@ -7577,7 +7681,7 @@ snapshots:
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
- path-to-regexp: 8.3.0
+ path-to-regexp: 8.4.2
transitivePeerDependencies:
- supports-color
@@ -7615,8 +7719,14 @@ snapshots:
dependencies:
seroval: 1.5.1
+ seroval-plugins@1.5.4(seroval@1.5.4):
+ dependencies:
+ seroval: 1.5.4
+
seroval@1.5.1: {}
+ seroval@1.5.4: {}
+
serve-static@2.2.1:
dependencies:
encodeurl: 2.0.0
@@ -7626,34 +7736,36 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ set-cookie-parser@3.1.0: {}
+
setprototypeof@1.2.0: {}
- shadcn@4.1.0(@types/node@25.5.0)(typescript@5.9.3):
+ shadcn@4.3.0(@types/node@25.6.0)(typescript@5.9.3):
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.2
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.0)
- '@dotenvx/dotenvx': 1.57.0
- '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76)
+ '@dotenvx/dotenvx': 1.61.0
+ '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76)
'@types/validate-npm-package-name': 4.0.2
- browserslist: 4.28.1
+ browserslist: 4.28.2
commander: 14.0.3
cosmiconfig: 9.0.1(typescript@5.9.3)
dedent: 1.7.2
deepmerge: 4.3.1
- diff: 8.0.3
+ diff: 8.0.4
execa: 9.6.1
fast-glob: 3.3.3
fs-extra: 11.3.4
fuzzysort: 3.1.0
https-proxy-agent: 7.0.6
kleur: 4.1.5
- msw: 2.12.13(@types/node@25.5.0)(typescript@5.9.3)
+ msw: 2.13.4(@types/node@25.6.0)(typescript@5.9.3)
node-fetch: 3.3.2
open: 11.0.0
ora: 8.2.0
- postcss: 8.5.8
+ postcss: 8.5.10
postcss-selector-parser: 7.1.1
prompts: 2.4.2
recast: 0.23.11
@@ -7663,7 +7775,7 @@ snapshots:
tsconfig-paths: 4.2.0
validate-npm-package-name: 7.0.2
zod: 3.25.76
- zod-to-json-schema: 3.25.1(zod@3.25.76)
+ zod-to-json-schema: 3.25.2(zod@3.25.76)
transitivePeerDependencies:
- '@cfworker/json-schema'
- '@types/node'
@@ -7677,7 +7789,7 @@ snapshots:
shebang-regex@3.0.0: {}
- side-channel-list@1.0.0:
+ side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
@@ -7701,7 +7813,7 @@ snapshots:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
- side-channel-list: 1.0.0
+ side-channel-list: 1.0.1
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
@@ -7711,10 +7823,10 @@ snapshots:
sisteransi@1.0.5: {}
- sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
source-map-js@1.2.1: {}
@@ -7772,8 +7884,6 @@ snapshots:
strip-final-newline@4.0.0: {}
- strip-json-comments@3.1.1: {}
-
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
@@ -7782,32 +7892,26 @@ snapshots:
dependencies:
inline-style-parser: 0.2.7
- supports-color@7.2.0:
- dependencies:
- has-flag: 4.0.0
-
tagged-tag@1.0.0: {}
tailwind-merge@3.5.0: {}
- tailwindcss@4.2.2: {}
+ tailwindcss@4.2.4: {}
- tapable@2.3.0: {}
+ tapable@2.3.3: {}
tiny-invariant@1.3.3: {}
- tiny-warning@1.0.3: {}
-
- tinyglobby@0.2.15:
+ tinyglobby@0.2.16:
dependencies:
- fdir: 6.5.0(picomatch@4.0.3)
- picomatch: 4.0.3
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
- tldts-core@7.0.26: {}
+ tldts-core@7.0.28: {}
- tldts@7.0.26:
+ tldts@7.0.28:
dependencies:
- tldts-core: 7.0.26
+ tldts-core: 7.0.28
to-regex-range@5.0.1:
dependencies:
@@ -7817,7 +7921,7 @@ snapshots:
tough-cookie@6.0.1:
dependencies:
- tldts: 7.0.26
+ tldts: 7.0.28
trim-lines@3.0.1: {}
@@ -7843,7 +7947,7 @@ snapshots:
tsx@4.21.0:
dependencies:
esbuild: 0.27.4
- get-tsconfig: 4.13.6
+ get-tsconfig: 4.13.7
optionalDependencies:
fsevents: 2.3.3
@@ -7863,20 +7967,20 @@ snapshots:
media-typer: 1.1.0
mime-types: 3.0.2
- typescript-eslint@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
+ typescript-eslint@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/parser': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3)
- '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- eslint: 9.39.4(jiti@2.6.1)
+ '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
+ eslint: 10.2.1(jiti@2.7.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
typescript@5.9.3: {}
- undici-types@7.18.2: {}
+ undici-types@7.19.2: {}
unicorn-magic@0.3.0: {}
@@ -7890,6 +7994,11 @@ snapshots:
trough: 2.2.0
vfile: 6.0.3
+ unist-util-find-after@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
unist-util-is@6.0.1:
dependencies:
'@types/unist': 3.0.3
@@ -7921,14 +8030,14 @@ snapshots:
dependencies:
'@jridgewell/remapping': 2.3.5
acorn: 8.16.0
- picomatch: 4.0.3
+ picomatch: 4.0.4
webpack-virtual-modules: 0.6.2
until-async@3.0.2: {}
- update-browserslist-db@1.2.3(browserslist@4.28.1):
+ update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies:
- browserslist: 4.28.1
+ browserslist: 4.28.2
escalade: 3.2.0
picocolors: 1.1.1
@@ -7936,43 +8045,43 @@ snapshots:
dependencies:
punycode: 2.3.1
- use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4):
+ use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
+ react: 19.2.5
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
- use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.4):
+ use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.4):
+ use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- use-latest@1.3.0(@types/react@19.2.14)(react@19.2.4):
+ use-latest@1.3.0(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
- use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
- use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4):
+ use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.5):
dependencies:
detect-node-es: 1.1.0
- react: 19.2.4
+ react: 19.2.5
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
- use-sync-external-store@1.6.0(react@19.2.4):
+ use-sync-external-store@1.6.0(react@19.2.5):
dependencies:
- react: 19.2.4
+ react: 19.2.5
util-deprecate@1.0.2: {}
@@ -7995,19 +8104,18 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
- vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0):
+ vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0):
dependencies:
- esbuild: 0.27.4
- fdir: 6.5.0(picomatch@4.0.3)
- picomatch: 4.0.3
- postcss: 8.5.8
- rollup: 4.59.0
- tinyglobby: 0.2.15
- optionalDependencies:
- '@types/node': 25.5.0
- fsevents: 2.3.3
- jiti: 2.6.1
lightningcss: 1.32.0
+ picomatch: 4.0.4
+ postcss: 8.5.10
+ rolldown: 1.0.0-rc.17
+ tinyglobby: 0.2.16
+ optionalDependencies:
+ '@types/node': 25.6.0
+ esbuild: 0.27.4
+ fsevents: 2.3.3
+ jiti: 2.7.0
tsx: 4.21.0
void-elements@3.1.0: {}
@@ -8034,12 +8142,6 @@ snapshots:
string-width: 8.2.0
strip-ansi: 7.2.0
- wrap-ansi@6.2.0:
- dependencies:
- ansi-styles: 4.3.0
- string-width: 4.2.3
- strip-ansi: 6.0.1
-
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -8071,11 +8173,13 @@ snapshots:
yocto-queue@0.1.0: {}
- yoctocolors-cjs@2.1.3: {}
+ yocto-spinner@1.1.0:
+ dependencies:
+ yoctocolors: 2.1.2
yoctocolors@2.1.2: {}
- zod-to-json-schema@3.25.1(zod@3.25.76):
+ zod-to-json-schema@3.25.2(zod@3.25.76):
dependencies:
zod: 3.25.76
diff --git a/web/frontend/src/api/channels.ts b/web/frontend/src/api/channels.ts
index eb4d41fd7..42a3a0606 100644
--- a/web/frontend/src/api/channels.ts
+++ b/web/frontend/src/api/channels.ts
@@ -1,5 +1,3 @@
-// API client for channels navigation and channel-specific config flows.
-
import { launcherFetch } from "@/api/http"
export type ChannelConfig = Record
@@ -12,6 +10,13 @@ export interface SupportedChannel {
variant?: string
}
+export interface ChannelConfigResponse {
+ config: ChannelConfig
+ configured_secrets: string[]
+ config_key: string
+ variant?: string
+}
+
interface ChannelsCatalogResponse {
channels: SupportedChannel[]
}
@@ -54,6 +59,14 @@ export async function getAppConfig(): Promise {
return request("/api/config")
}
+export async function getChannelConfig(
+ channelName: string,
+): Promise {
+ return request(
+ `/api/channels/${encodeURIComponent(channelName)}/config`,
+ )
+}
+
export async function patchAppConfig(
patch: Record,
): Promise {
diff --git a/web/frontend/src/api/http.ts b/web/frontend/src/api/http.ts
index 0eb872f3f..347dd9373 100644
--- a/web/frontend/src/api/http.ts
+++ b/web/frontend/src/api/http.ts
@@ -1,14 +1,14 @@
-import { isLauncherLoginPathname } from "@/lib/launcher-login-path"
+import { isLauncherAuthPathname } from "@/lib/launcher-login-path"
-function isLauncherLoginPath(): boolean {
+function isLauncherAuthPath(): boolean {
if (typeof globalThis.location === "undefined") {
return false
}
- if (isLauncherLoginPathname(globalThis.location.pathname || "/")) {
+ if (isLauncherAuthPathname(globalThis.location.pathname || "/")) {
return true
}
try {
- return isLauncherLoginPathname(
+ return isLauncherAuthPathname(
new URL(globalThis.location.href).pathname || "/",
)
} catch {
@@ -18,7 +18,7 @@ function isLauncherLoginPath(): boolean {
/**
* Same-origin fetch that sends cookies; redirects to launcher login on 401 JSON responses.
- * Skips redirect while already on the login page to avoid reload loops (e.g. gateway poll).
+ * Skips redirect while already on an auth page (login or setup) to avoid reload loops.
*/
export async function launcherFetch(
input: RequestInfo | URL,
@@ -33,7 +33,7 @@ export async function launcherFetch(
if (
ct.includes("application/json") &&
typeof globalThis.location !== "undefined" &&
- !isLauncherLoginPath()
+ !isLauncherAuthPath()
) {
globalThis.location.assign("/launcher-login")
}
diff --git a/web/frontend/src/api/launcher-auth.ts b/web/frontend/src/api/launcher-auth.ts
index 247d5ab9e..c7318d962 100644
--- a/web/frontend/src/api/launcher-auth.ts
+++ b/web/frontend/src/api/launcher-auth.ts
@@ -1,29 +1,33 @@
/**
- * Dashboard launcher token login. Uses plain fetch (not launcherFetch) to avoid
- * redirect loops on 401 while on the login page.
+ * Dashboard launcher auth API.
+ * Uses plain fetch (not launcherFetch) to avoid redirect loops on auth pages.
*/
+export type LoginResult =
+ | { ok: true }
+ | { ok: false; status: number; error: string }
+
export async function postLauncherDashboardLogin(
- token: string,
-): Promise {
+ password: string,
+): Promise {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
- body: JSON.stringify({ token: token.trim() }),
+ body: JSON.stringify({ password: password.trim() }),
})
- return res.ok
-}
+ if (res.ok) return { ok: true }
-export type LauncherAuthTokenHelp = {
- env_var_name: string
- log_file?: string
- tray_copy_menu: boolean
- console_stdout: boolean
+ return {
+ ok: false,
+ status: res.status,
+ error: await readLauncherAuthError(res),
+ }
}
export type LauncherAuthStatus = {
authenticated: boolean
- token_help?: LauncherAuthTokenHelp
+ /** true when a bcrypt password has been stored in the DB */
+ initialized: boolean
}
export async function getLauncherAuthStatus(): Promise {
@@ -46,3 +50,33 @@ export async function postLauncherDashboardLogout(): Promise {
})
return res.ok
}
+
+export type SetupResult = { ok: true } | { ok: false; error: string }
+
+export async function postLauncherDashboardSetup(
+ password: string,
+ confirm: string,
+): Promise {
+ const res = await fetch("/api/auth/setup", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "same-origin",
+ body: JSON.stringify({
+ password: password.trim(),
+ confirm: confirm.trim(),
+ }),
+ })
+ if (res.ok) return { ok: true }
+ return { ok: false, error: await readLauncherAuthError(res) }
+}
+
+async function readLauncherAuthError(res: Response): Promise {
+ let msg = `Request failed with status ${res.status}`
+ try {
+ const j = (await res.json()) as { error?: string }
+ if (j.error) msg = j.error
+ } catch {
+ /* ignore */
+ }
+ return msg
+}
diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts
index d75b3ec3c..5bb275fde 100644
--- a/web/frontend/src/api/models.ts
+++ b/web/frontend/src/api/models.ts
@@ -6,6 +6,7 @@ import { refreshGatewayState } from "@/store/gateway"
export interface ModelInfo {
index: number
model_name: string
+ provider?: string
model: string
api_base?: string
api_key: string
@@ -18,17 +19,32 @@ export interface ModelInfo {
max_tokens_field?: string
request_timeout?: number
thinking_level?: string
+ tool_schema_transform?: string
extra_body?: Record
+ custom_headers?: Record
// Meta
- configured: boolean
+ available: boolean
+ status: "available" | "unconfigured" | "unreachable"
is_default: boolean
is_virtual: boolean
+ default_model_allowed?: boolean
+}
+
+export interface ModelProviderOption {
+ id: string
+ default_api_base: string
+ empty_api_key_allowed: boolean
+ create_allowed: boolean
+ default_model_allowed: boolean
+ default_auth_method?: string
+ auth_method_locked?: boolean
}
interface ModelsListResponse {
models: ModelInfo[]
total: number
default_model: string
+ provider_options: ModelProviderOption[]
}
interface ModelActionResponse {
diff --git a/web/frontend/src/api/pico.ts b/web/frontend/src/api/pico.ts
index 6b8ceb49a..ca98a06da 100644
--- a/web/frontend/src/api/pico.ts
+++ b/web/frontend/src/api/pico.ts
@@ -2,16 +2,16 @@ import { launcherFetch } from "@/api/http"
// API client for Pico Channel configuration.
-interface PicoTokenResponse {
- token: string
+interface PicoInfoResponse {
ws_url: string
enabled: boolean
+ configured?: boolean
}
interface PicoSetupResponse {
- token: string
ws_url: string
enabled: boolean
+ configured?: boolean
changed: boolean
}
@@ -25,16 +25,16 @@ async function request(path: string, options?: RequestInit): Promise {
return res.json() as Promise
}
-export async function getPicoToken(): Promise {
- return request("/api/pico/token")
+export async function getPicoInfo(): Promise {
+ return request("/api/pico/info")
}
-export async function regenPicoToken(): Promise {
- return request("/api/pico/token", { method: "POST" })
+export async function regenPicoToken(): Promise {
+ return request("/api/pico/token", { method: "POST" })
}
export async function setupPico(): Promise {
return request("/api/pico/setup", { method: "POST" })
}
-export type { PicoTokenResponse, PicoSetupResponse }
+export type { PicoInfoResponse, PicoSetupResponse }
diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts
index c91495901..edd7d7c27 100644
--- a/web/frontend/src/api/sessions.ts
+++ b/web/frontend/src/api/sessions.ts
@@ -1,5 +1,3 @@
-// Sessions API — list and retrieve chat session history
-
import { launcherFetch } from "@/api/http"
export interface SessionSummary {
@@ -13,7 +11,29 @@ export interface SessionSummary {
export interface SessionDetail {
id: string
- messages: { role: "user" | "assistant"; content: string }[]
+ messages: {
+ role: "user" | "assistant"
+ content: string
+ kind?: "normal" | "thought" | "tool_calls"
+ media?: string[]
+ attachments?: {
+ type?: "image" | "audio" | "video" | "file"
+ url: string
+ filename?: string
+ content_type?: string
+ }[]
+ tool_calls?: {
+ id?: string
+ type?: string
+ function?: {
+ name?: string
+ arguments?: string
+ }
+ extra_content?: {
+ tool_feedback_explanation?: string
+ }
+ }[]
+ }[]
summary: string
created: string
updated: string
diff --git a/web/frontend/src/api/skills.ts b/web/frontend/src/api/skills.ts
index 72ccbcfe5..958808afd 100644
--- a/web/frontend/src/api/skills.ts
+++ b/web/frontend/src/api/skills.ts
@@ -5,22 +5,60 @@ export interface SkillSupportItem {
path: string
source: "workspace" | "global" | "builtin" | string
description: string
+ origin_kind: "builtin" | "third_party" | "manual" | string
+ registry_name?: string
+ registry_url?: string
+ installed_version?: string
+ installed_at?: number
}
export interface SkillDetailResponse extends SkillSupportItem {
content: string
}
+export interface SkillRegistrySearchResult {
+ score: number
+ slug: string
+ display_name: string
+ summary: string
+ version: string
+ registry_name: string
+ url?: string
+ installed: boolean
+ installed_name?: string
+}
+
interface SkillsResponse {
skills: SkillSupportItem[]
}
-interface SkillActionResponse {
+export interface SkillSearchResponse {
+ results: SkillRegistrySearchResult[]
+ limit: number
+ offset: number
+ next_offset?: number
+ has_more: boolean
+}
+
+type SkillActionResponse = Partial & {
status?: string
- name?: string
- path?: string
- source?: string
- description?: string
+}
+
+export interface InstallSkillRequest {
+ slug: string
+ registry: string
+ version?: string
+ force?: boolean
+}
+
+export interface InstallSkillResponse {
+ status: string
+ slug: string
+ registry: string
+ version: string
+ summary?: string
+ is_suspicious?: boolean
+ skill?: SkillSupportItem
}
async function request(path: string, options?: RequestInit): Promise {
@@ -39,6 +77,29 @@ export async function getSkill(name: string): Promise {
return request(`/api/skills/${encodeURIComponent(name)}`)
}
+export async function searchSkills(
+ query: string,
+ limit = 20,
+ offset = 0,
+): Promise {
+ const params = new URLSearchParams({
+ q: query,
+ limit: String(limit),
+ offset: String(offset),
+ })
+ return request(`/api/skills/search?${params.toString()}`)
+}
+
+export async function installSkill(
+ input: InstallSkillRequest,
+): Promise {
+ return request("/api/skills/install", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(input),
+ })
+}
+
export async function importSkill(file: File): Promise {
const formData = new FormData()
formData.set("file", file)
@@ -64,15 +125,23 @@ export async function deleteSkill(name: string): Promise {
async function extractErrorMessage(res: Response): Promise {
try {
- const body = (await res.json()) as {
- error?: string
- errors?: string[]
+ const raw = await res.text()
+ if (raw.trim() === "") {
+ return `API error: ${res.status} ${res.statusText}`
}
- if (Array.isArray(body.errors) && body.errors.length > 0) {
- return body.errors.join("; ")
- }
- if (typeof body.error === "string" && body.error.trim() !== "") {
- return body.error
+ try {
+ const body = JSON.parse(raw) as {
+ error?: string
+ errors?: string[]
+ }
+ if (Array.isArray(body.errors) && body.errors.length > 0) {
+ return body.errors.join("; ")
+ }
+ if (typeof body.error === "string" && body.error.trim() !== "") {
+ return body.error
+ }
+ } catch {
+ return raw.trim()
}
} catch {
// ignore invalid body
diff --git a/web/frontend/src/api/system.ts b/web/frontend/src/api/system.ts
index 2e2f36f15..dfc48b6b8 100644
--- a/web/frontend/src/api/system.ts
+++ b/web/frontend/src/api/system.ts
@@ -13,6 +13,13 @@ export interface LauncherConfig {
allowed_cidrs: string[]
}
+export interface SystemVersionInfo {
+ version: string
+ git_commit?: string
+ build_time?: string
+ go_version: string
+}
+
async function request(path: string, options?: RequestInit): Promise {
const res = await launcherFetch(path, options)
if (!res.ok) {
@@ -62,3 +69,7 @@ export async function setLauncherConfig(
body: JSON.stringify(payload),
})
}
+
+export async function getSystemVersionInfo(): Promise {
+ return request("/api/system/version")
+}
diff --git a/web/frontend/src/api/tools.ts b/web/frontend/src/api/tools.ts
index 824bcc0fa..a77f3ba80 100644
--- a/web/frontend/src/api/tools.ts
+++ b/web/frontend/src/api/tools.ts
@@ -17,6 +17,31 @@ interface ToolActionResponse {
status: string
}
+export interface WebSearchProviderOption {
+ id: string
+ label: string
+ configured: boolean
+ current: boolean
+ requires_auth: boolean
+}
+
+export interface WebSearchProviderConfig {
+ enabled: boolean
+ max_results: number
+ base_url?: string
+ api_key?: string
+ api_key_set?: boolean
+}
+
+export interface WebSearchConfigResponse {
+ provider: string
+ current_service: string
+ prefer_native: boolean
+ proxy?: string
+ providers: WebSearchProviderOption[]
+ settings: Record
+}
+
async function request(path: string, options?: RequestInit): Promise {
const res = await launcherFetch(path, options)
if (!res.ok) {
@@ -56,3 +81,17 @@ export async function setToolEnabled(
},
)
}
+
+export async function getWebSearchConfig(): Promise {
+ return request("/api/tools/web-search-config")
+}
+
+export async function updateWebSearchConfig(
+ payload: WebSearchConfigResponse,
+): Promise {
+ return request("/api/tools/web-search-config", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ })
+}
diff --git a/web/frontend/src/app-providers.tsx b/web/frontend/src/app-providers.tsx
new file mode 100644
index 000000000..bfb5dfb38
--- /dev/null
+++ b/web/frontend/src/app-providers.tsx
@@ -0,0 +1,13 @@
+import type { ReactNode } from "react"
+
+import { useHighlightTheme } from "./hooks/use-highlight-theme"
+
+interface AppProvidersProps {
+ children: ReactNode
+}
+
+export function AppProviders({ children }: AppProvidersProps) {
+ useHighlightTheme()
+
+ return <>{children}>
+}
diff --git a/web/frontend/src/components/agent/hub/hub-page.tsx b/web/frontend/src/components/agent/hub/hub-page.tsx
new file mode 100644
index 000000000..69f0be638
--- /dev/null
+++ b/web/frontend/src/components/agent/hub/hub-page.tsx
@@ -0,0 +1,51 @@
+import { useTranslation } from "react-i18next"
+
+import { PageHeader } from "@/components/page-header"
+
+import { ResultsPanel } from "./results-panel"
+import { SearchPanel } from "./search-panel"
+import { useHubMarketplace } from "./use-hub-marketplace"
+
+export function HubPage() {
+ const { t } = useTranslation()
+ const hub = useHubMarketplace()
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/hub/market-skill-card.tsx b/web/frontend/src/components/agent/hub/market-skill-card.tsx
new file mode 100644
index 000000000..99b00db92
--- /dev/null
+++ b/web/frontend/src/components/agent/hub/market-skill-card.tsx
@@ -0,0 +1,158 @@
+import {
+ IconCheck,
+ IconFileInfo,
+ IconLoader2,
+ IconPlus,
+} from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import {
+ type SkillRegistrySearchResult,
+ type SkillSupportItem,
+} from "@/api/skills"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+export function MarketSkillCard({
+ result,
+ canInstall,
+ installPending,
+ installedSkill,
+ onInstall,
+ onViewInstalled,
+}: {
+ result: SkillRegistrySearchResult
+ canInstall: boolean
+ installPending: boolean
+ installedSkill: SkillSupportItem | null
+ onInstall: () => void
+ onViewInstalled: () => void
+}) {
+ const { t } = useTranslation()
+
+ const installDisabledReason = (() => {
+ if (installPending)
+ return t("pages.agent.skills.marketplace_installDisabled.installing")
+ if (result.installed)
+ return t("pages.agent.skills.marketplace_installDisabled.installed")
+ if (!canInstall)
+ return t("pages.agent.skills.marketplace_installDisabled.cannotInstall")
+ return t("pages.agent.skills.marketplace_install_action")
+ })()
+ const installDisabled = !canInstall || result.installed || installPending
+
+ return (
+
+ {result.installed && (
+
+ )}
+
+
+
+
+
+ {result.display_name || result.slug}
+
+
+ {result.registry_name}
+
+ {result.installed ? (
+
+ {t("pages.agent.skills.marketplace_installed")}
+
+ ) : null}
+
+
+ {result.slug}
+ {result.version ? (
+
+ {" "}
+ · v{result.version}
+
+ ) : null}
+
+
+ {result.summary}
+
+ {result.url ? (
+
+
+ {result.url}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {installDisabledReason}
+
+ {result.installed && installedSkill ? (
+
+ ) : null}
+
+
+
+ {result.installed_name ? (
+
+
+ {t("pages.agent.skills.marketplace_installed_hint", {
+ name: result.installed_name,
+ })}
+
+
+ ) : null}
+
+ )
+}
diff --git a/web/frontend/src/components/agent/hub/results-panel.tsx b/web/frontend/src/components/agent/hub/results-panel.tsx
new file mode 100644
index 000000000..e2a351955
--- /dev/null
+++ b/web/frontend/src/components/agent/hub/results-panel.tsx
@@ -0,0 +1,135 @@
+import { IconLoader2, IconSearch, IconX } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import {
+ type SkillRegistrySearchResult,
+ type SkillSupportItem,
+} from "@/api/skills"
+
+import { MarketSkillCard } from "./market-skill-card"
+
+export function ResultsPanel({
+ canSearchMarketplace,
+ hasSubmittedQuery,
+ submittedQuery,
+ marketResults,
+ marketSearchError,
+ isMarketSearchInitialLoading,
+ isMarketSearchLoadingMore,
+ canInstallFromMarketplace,
+ getInstalledSkill,
+ isInstallPending,
+ onInstall,
+ onViewInstalled,
+}: {
+ canSearchMarketplace: boolean
+ hasSubmittedQuery: boolean
+ submittedQuery: string
+ marketResults: SkillRegistrySearchResult[]
+ marketSearchError: unknown
+ isMarketSearchInitialLoading: boolean
+ isMarketSearchLoadingMore: boolean
+ canInstallFromMarketplace: boolean
+ getInstalledSkill: (installedName?: string) => SkillSupportItem | null
+ isInstallPending: (result: SkillRegistrySearchResult) => boolean
+ onInstall: (result: SkillRegistrySearchResult) => void
+ onViewInstalled: () => void
+}) {
+ const { t } = useTranslation()
+
+ return (
+
+
+ {canSearchMarketplace && hasSubmittedQuery ? (
+
+
+
+ {t("pages.agent.skills.marketplace_notice_title")}
+
+
+ {t("pages.agent.skills.marketplace_notice_body")}
+
+
+
+ {isMarketSearchInitialLoading ? (
+
+
+
+ {t("pages.agent.skills.marketplace_loading_results")}
+
+
+ ) : marketSearchError ? (
+
+
+
+
+ {marketSearchError instanceof Error
+ ? marketSearchError.message
+ : t("pages.agent.skills.marketplace_search_error")}
+
+
+
+ ) : marketResults.length ? (
+
+
+
+ {t("pages.agent.skills.marketplace_results_title", {
+ query: submittedQuery,
+ count: marketResults.length,
+ })}
+
+
+ {t("pages.agent.skills.marketplace_results_hint")}
+
+
+
+ {marketResults.map((result) => (
+ onInstall(result)}
+ onViewInstalled={onViewInstalled}
+ />
+ ))}
+
+ {isMarketSearchLoadingMore ? (
+
+
+
+ {t("pages.agent.skills.marketplace_loading_more")}
+
+
+ ) : null}
+
+ ) : (
+
+
+
+ {t("pages.agent.skills.marketplace_empty_results", {
+ query: submittedQuery,
+ })}
+
+
+ )}
+
+ ) : !canSearchMarketplace ? (
+
+
+ {t("pages.agent.skills.marketplace_unavailable")}
+
+
+ ) : (
+
+
+
+ {t("pages.agent.skills.marketplace_idle")}
+
+
+ )}
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/hub/search-panel.tsx b/web/frontend/src/components/agent/hub/search-panel.tsx
new file mode 100644
index 000000000..875aaad6b
--- /dev/null
+++ b/web/frontend/src/components/agent/hub/search-panel.tsx
@@ -0,0 +1,91 @@
+import { IconLoader2 } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+
+import type { UnavailableToolMessage } from "./tool-support"
+
+export function SearchPanel({
+ marketQuery,
+ canSearchMarketplace,
+ isMarketSearchInitialLoading,
+ unavailableToolMessages,
+ onMarketQueryChange,
+ onSearchSubmit,
+}: {
+ marketQuery: string
+ canSearchMarketplace: boolean
+ isMarketSearchInitialLoading: boolean
+ unavailableToolMessages: UnavailableToolMessage[]
+ onMarketQueryChange: (value: string) => void
+ onSearchSubmit: () => void
+}) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+ {t("pages.agent.skills.marketplace_title", {
+ defaultValue: "Discover Skills",
+ })}
+
+
+ {t("pages.agent.skills.marketplace_description")}
+
+
+
+
+
+ {unavailableToolMessages.length ? (
+
+ {unavailableToolMessages.map((item) => (
+
+ {item.label}
+ {item.message}
+
+ ))}
+
+ ) : null}
+
+ )
+}
diff --git a/web/frontend/src/components/agent/hub/tool-support.ts b/web/frontend/src/components/agent/hub/tool-support.ts
new file mode 100644
index 000000000..1553b156a
--- /dev/null
+++ b/web/frontend/src/components/agent/hub/tool-support.ts
@@ -0,0 +1,56 @@
+import type { TFunction } from "i18next"
+
+import type { ToolSupportItem } from "@/api/tools"
+
+type MarketplaceTool =
+ | Pick
+ | undefined
+
+export interface UnavailableToolMessage {
+ key: "search" | "install"
+ label: string
+ message: string
+}
+
+export function buildUnavailableToolMessages({
+ searchTool,
+ installTool,
+ t,
+}: {
+ searchTool: MarketplaceTool
+ installTool: MarketplaceTool
+ t: TFunction
+}): UnavailableToolMessage[] {
+ const searchMessage = getToolSupportMessage(searchTool, t)
+ const installMessage = getToolSupportMessage(installTool, t)
+
+ return [
+ searchMessage
+ ? {
+ key: "search",
+ label: t("pages.agent.skills.marketplace_search_status"),
+ message: searchMessage,
+ }
+ : null,
+ installMessage
+ ? {
+ key: "install",
+ label: t("pages.agent.skills.marketplace_install_status"),
+ message: installMessage,
+ }
+ : null,
+ ].filter((item): item is UnavailableToolMessage => Boolean(item))
+}
+
+function getToolSupportMessage(
+ tool: MarketplaceTool,
+ t: TFunction,
+): string | null {
+ if (!tool || tool.status === "enabled") {
+ return null
+ }
+ if (tool.reason_code) {
+ return `${t(`pages.agent.tools.reasons.${tool.reason_code}`)} ${t("pages.agent.skills.marketplace_status_enable_hint")}`
+ }
+ return t("pages.agent.skills.marketplace_status_disabled")
+}
diff --git a/web/frontend/src/components/agent/hub/use-hub-marketplace.ts b/web/frontend/src/components/agent/hub/use-hub-marketplace.ts
new file mode 100644
index 000000000..2777aa376
--- /dev/null
+++ b/web/frontend/src/components/agent/hub/use-hub-marketplace.ts
@@ -0,0 +1,211 @@
+import {
+ useInfiniteQuery,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query"
+import { useNavigate } from "@tanstack/react-router"
+import { type UIEvent, useEffect, useRef, useState } from "react"
+import { useTranslation } from "react-i18next"
+import { toast } from "sonner"
+
+import {
+ type SkillRegistrySearchResult,
+ type SkillSearchResponse,
+ type SkillSupportItem,
+ getSkills,
+ installSkill,
+ searchSkills,
+} from "@/api/skills"
+import { getTools } from "@/api/tools"
+
+import { buildUnavailableToolMessages } from "./tool-support"
+
+const MARKET_SEARCH_LIMIT = 20
+
+export function useHubMarketplace() {
+ const { t } = useTranslation()
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+ const isLoadMoreLockedRef = useRef(false)
+
+ const [marketQuery, setMarketQuery] = useState("")
+ const [submittedMarketQuery, setSubmittedMarketQuery] = useState("")
+
+ const { data: skillsData } = useQuery({
+ queryKey: ["skills"],
+ queryFn: getSkills,
+ })
+ const { data: toolsData } = useQuery({
+ queryKey: ["tools"],
+ queryFn: getTools,
+ })
+
+ const findSkillsTool = toolsData?.tools.find(
+ (tool) => tool.name === "find_skills",
+ )
+ const installSkillTool = toolsData?.tools.find(
+ (tool) => tool.name === "install_skill",
+ )
+ const canSearchMarketplace = findSkillsTool?.status === "enabled"
+ const canInstallFromMarketplace = installSkillTool?.status === "enabled"
+ const hasSubmittedQuery = submittedMarketQuery.trim() !== ""
+ const isMarketSearchActive = canSearchMarketplace && hasSubmittedQuery
+
+ const {
+ data: marketSearchData,
+ isPending: isMarketSearchPending,
+ isFetching: isMarketSearchFetching,
+ isFetchingNextPage,
+ error: marketSearchError,
+ hasNextPage,
+ fetchNextPage,
+ refetch: refetchMarketSearch,
+ } = useInfiniteQuery({
+ queryKey: ["skills-marketplace", submittedMarketQuery],
+ initialPageParam: 0,
+ queryFn: ({ pageParam }) =>
+ searchSkills(
+ submittedMarketQuery,
+ MARKET_SEARCH_LIMIT,
+ Number(pageParam) || 0,
+ ),
+ getNextPageParam: (lastPage: SkillSearchResponse) =>
+ lastPage.has_more ? (lastPage.next_offset ?? undefined) : undefined,
+ enabled: isMarketSearchActive,
+ staleTime: 5 * 60 * 1000,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ })
+
+ const installMutation = useMutation({
+ mutationFn: installSkill,
+ onSuccess: (response) => {
+ toast.success(
+ t("pages.agent.skills.install_success", {
+ name: response.skill?.name ?? response.slug,
+ }),
+ )
+ void queryClient.invalidateQueries({ queryKey: ["skills"] })
+ void queryClient.invalidateQueries({ queryKey: ["skills-marketplace"] })
+ },
+ onError: (err) => {
+ toast.error(
+ err instanceof Error
+ ? err.message
+ : t("pages.agent.skills.install_error"),
+ )
+ },
+ })
+
+ const allSkills = skillsData?.skills ?? []
+ const workspaceSkillsByName = new Map(
+ allSkills
+ .filter((skill) => skill.source === "workspace")
+ .map((skill) => [skill.name, skill] as const),
+ )
+ const marketResults =
+ marketSearchData?.pages.flatMap((page) => page.results) ?? []
+ const hasMoreMarketResults = hasNextPage ?? false
+ const isMarketSearchInitialLoading =
+ isMarketSearchActive &&
+ !marketSearchData &&
+ (isMarketSearchPending || isMarketSearchFetching)
+ const isMarketSearchLoadingMore =
+ isMarketSearchActive && Boolean(marketSearchData) && isFetchingNextPage
+ const installPendingKey =
+ installMutation.isPending && installMutation.variables
+ ? `${installMutation.variables.registry}:${installMutation.variables.slug}`
+ : null
+
+ const unavailableToolMessages = buildUnavailableToolMessages({
+ searchTool: findSkillsTool,
+ installTool: installSkillTool,
+ t,
+ })
+
+ useEffect(() => {
+ if (!isFetchingNextPage) {
+ isLoadMoreLockedRef.current = false
+ }
+ }, [isFetchingNextPage])
+
+ const handleSearchSubmit = () => {
+ const nextQuery = marketQuery.trim()
+ if (!canSearchMarketplace || nextQuery === "") {
+ return
+ }
+
+ isLoadMoreLockedRef.current = false
+ if (nextQuery === submittedMarketQuery) {
+ void refetchMarketSearch()
+ return
+ }
+
+ setSubmittedMarketQuery(nextQuery)
+ }
+
+ const handleInstall = (result: SkillRegistrySearchResult) => {
+ installMutation.mutate({
+ slug: result.slug,
+ registry: result.registry_name,
+ version: result.version || undefined,
+ })
+ }
+
+ const handleViewInstalled = () => {
+ void navigate({ to: "/agent/skills" })
+ }
+
+ const handleScroll = (event: UIEvent) => {
+ if (
+ !isMarketSearchActive ||
+ !hasMoreMarketResults ||
+ isFetchingNextPage ||
+ isLoadMoreLockedRef.current
+ ) {
+ return
+ }
+
+ const node = event.currentTarget
+ const remaining = node.scrollHeight - node.scrollTop - node.clientHeight
+ if (remaining > 240) {
+ return
+ }
+
+ isLoadMoreLockedRef.current = true
+ void fetchNextPage()
+ }
+
+ const getInstalledSkill = (
+ installedName?: string,
+ ): SkillSupportItem | null => {
+ if (!installedName) {
+ return null
+ }
+ return workspaceSkillsByName.get(installedName) ?? null
+ }
+
+ const isInstallPending = (result: SkillRegistrySearchResult) =>
+ installPendingKey === `${result.registry_name}:${result.slug}`
+
+ return {
+ marketQuery,
+ submittedMarketQuery,
+ canSearchMarketplace,
+ canInstallFromMarketplace,
+ marketResults,
+ marketSearchError,
+ unavailableToolMessages,
+ hasSubmittedQuery,
+ isMarketSearchInitialLoading,
+ isMarketSearchLoadingMore,
+ setMarketQuery,
+ handleSearchSubmit,
+ handleInstall,
+ handleViewInstalled,
+ handleScroll,
+ getInstalledSkill,
+ isInstallPending,
+ }
+}
diff --git a/web/frontend/src/components/agent/skills/delete-dialog.tsx b/web/frontend/src/components/agent/skills/delete-dialog.tsx
new file mode 100644
index 000000000..1f4eba4c3
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/delete-dialog.tsx
@@ -0,0 +1,66 @@
+import { IconLoader2, IconTrash } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import type { SkillSupportItem } from "@/api/skills"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+
+interface DeleteDialogProps {
+ open: boolean
+ skillPendingDelete: SkillSupportItem | null
+ isDeletePending: boolean
+ onOpenChange: (open: boolean) => void
+ onConfirm: () => void
+}
+
+export function DeleteDialog({
+ open,
+ skillPendingDelete,
+ isDeletePending,
+ onOpenChange,
+ onConfirm,
+}: DeleteDialogProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
+ {t("pages.agent.skills.delete_title")}
+
+
+ {t("pages.agent.skills.delete_description", {
+ name: skillPendingDelete?.name,
+ })}
+
+
+
+
+ {t("common.cancel")}
+
+
+ {isDeletePending ? (
+
+ ) : (
+
+ )}
+ {t("pages.agent.skills.delete_confirm")}
+
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/detail-sheet.tsx b/web/frontend/src/components/agent/skills/detail-sheet.tsx
new file mode 100644
index 000000000..4579926d8
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/detail-sheet.tsx
@@ -0,0 +1,248 @@
+import {
+ IconFileCode,
+ IconSparkles,
+ IconWorld,
+ IconX,
+} from "@tabler/icons-react"
+import type { ReactNode } from "react"
+import { useTranslation } from "react-i18next"
+import ReactMarkdown from "react-markdown"
+import rehypeHighlight from "rehype-highlight"
+import rehypeRaw from "rehype-raw"
+import rehypeSanitize from "rehype-sanitize"
+import remarkGfm from "remark-gfm"
+
+import type { SkillDetailResponse, SkillSupportItem } from "@/api/skills"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Skeleton } from "@/components/ui/skeleton"
+import { cn } from "@/lib/utils"
+
+import { OriginBadge } from "./origin-badge"
+import { getOriginLabel, getSkillOriginKind } from "./origin-utils"
+import type { SkillDetailView } from "./types"
+
+const DETAIL_VIEWS = [
+ "preview",
+ "raw",
+ "meta",
+] as const satisfies SkillDetailView[]
+
+interface DetailSheetProps {
+ open: boolean
+ selectedSkill: SkillSupportItem | null
+ selectedSkillDetail?: SkillDetailResponse
+ isLoading: boolean
+ error: unknown
+ detailView: SkillDetailView
+ onDetailViewChange: (view: SkillDetailView) => void
+ onOpenChange: (open: boolean) => void
+}
+
+export function DetailSheet({
+ open,
+ selectedSkill,
+ selectedSkillDetail,
+ isLoading,
+ error,
+ detailView,
+ onDetailViewChange,
+ onOpenChange,
+}: DetailSheetProps) {
+ const { t } = useTranslation()
+
+ const activeSkillDetail = selectedSkillDetail ?? selectedSkill
+ const activeSkillOrigin = activeSkillDetail
+ ? getSkillOriginKind(activeSkillDetail)
+ : null
+ const detailLineCount = selectedSkillDetail
+ ? selectedSkillDetail.content.split("\n").length
+ : 0
+ const detailCharacterCount = selectedSkillDetail?.content.length ?? 0
+
+ return (
+
+
+
+
+
+ {activeSkillDetail?.origin_kind === "builtin" ? (
+
+ ) : activeSkillDetail?.registry_name ? (
+
+ ) : (
+
+ )}
+
+
+
+ {activeSkillDetail?.name ||
+ t("pages.agent.skills.viewer_title")}
+
+
+ {activeSkillDetail?.description ||
+ t("pages.agent.skills.viewer_description")}
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+
+
+ ) : error ? (
+
+
+
+ {t("pages.agent.skills.load_detail_error")}
+
+
+ ) : selectedSkillDetail ? (
+
+ {activeSkillOrigin === "third_party" ? (
+
+
+
+
+
+
+ {selectedSkillDetail.registry_name ? (
+
+ ) : null}
+ {selectedSkillDetail.installed_version ? (
+
+ ) : null}
+ {selectedSkillDetail.registry_url ? (
+
+ {selectedSkillDetail.registry_url}
+
+ }
+ mono
+ />
+ ) : null}
+
+
+ ) : null}
+
+
+ {DETAIL_VIEWS.map((view) => (
+
+ ))}
+
+
+ {detailView === "preview" ? (
+
+
+ {selectedSkillDetail.content}
+
+
+ ) : null}
+
+ {detailView === "raw" ? (
+
+
+ {selectedSkillDetail.content}
+
+
+ ) : null}
+
+ {detailView === "meta" ? (
+
+
+
+
+
+
+ ) : null}
+
+ ) : null}
+
+
+
+ )
+}
+
+function MetadataItem({
+ label,
+ value,
+ mono = false,
+}: {
+ label: string
+ value: ReactNode
+ mono?: boolean
+}) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/filter-bar.tsx b/web/frontend/src/components/agent/skills/filter-bar.tsx
new file mode 100644
index 000000000..033609ea6
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/filter-bar.tsx
@@ -0,0 +1,132 @@
+import { IconLayoutGrid, IconLayoutList, IconSearch } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { cn } from "@/lib/utils"
+
+import { getOriginLabel } from "./origin-utils"
+import type { SkillLayoutMode, SkillSortOption } from "./types"
+
+interface FilterBarProps {
+ searchQuery: string
+ sourceFilter: string
+ availableOrigins: string[]
+ sortOrder: SkillSortOption
+ layoutMode: SkillLayoutMode
+ onSearchQueryChange: (value: string) => void
+ onSourceFilterChange: (value: string) => void
+ onSortOrderChange: (value: SkillSortOption) => void
+ onLayoutModeChange: (value: SkillLayoutMode) => void
+}
+
+export function FilterBar({
+ searchQuery,
+ sourceFilter,
+ availableOrigins,
+ sortOrder,
+ layoutMode,
+ onSearchQueryChange,
+ onSourceFilterChange,
+ onSortOrderChange,
+ onLayoutModeChange,
+}: FilterBarProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+ onSearchQueryChange(event.target.value)}
+ placeholder={t("pages.agent.skills.search_placeholder")}
+ className="hover:bg-background/50 focus-visible:bg-background h-9 border-transparent bg-transparent pl-9 shadow-none focus-visible:ring-1"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/import-dialog.tsx b/web/frontend/src/components/agent/skills/import-dialog.tsx
new file mode 100644
index 000000000..21f4827e3
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/import-dialog.tsx
@@ -0,0 +1,160 @@
+import { IconLoader2, IconUpload, IconX } from "@tabler/icons-react"
+import type { DragEvent } from "react"
+import { useTranslation } from "react-i18next"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { cn } from "@/lib/utils"
+
+interface ImportDialogProps {
+ open: boolean
+ isImportPending: boolean
+ isDragActive: boolean
+ onOpenChange: (open: boolean) => void
+ onImportClick: () => void
+ onDragEnter: (event: DragEvent) => void
+ onDragLeave: (event: DragEvent) => void
+ onDrop: (event: DragEvent) => void
+}
+
+export function ImportDialog({
+ open,
+ isImportPending,
+ isDragActive,
+ onOpenChange,
+ onImportClick,
+ onDragEnter,
+ onDragLeave,
+ onDrop,
+}: ImportDialogProps) {
+ const { t } = useTranslation()
+
+ return (
+
+ )
+}
+
+function SkillImportPanel({
+ isDragActive,
+ isImportPending,
+ onDragEnter,
+ onDragLeave,
+ onDrop,
+ onImportClick,
+}: {
+ isDragActive: boolean
+ isImportPending: boolean
+ onDragEnter: (event: DragEvent) => void
+ onDragLeave: (event: DragEvent) => void
+ onDrop: (event: DragEvent) => void
+ onImportClick: () => void
+}) {
+ const { t } = useTranslation()
+
+ return (
+
+ {
+ if (!isImportPending) {
+ onImportClick()
+ }
+ }}
+ onDragEnter={onDragEnter}
+ onDragLeave={onDragLeave}
+ onDragOver={(event) => event.preventDefault()}
+ onDrop={onDrop}
+ >
+
+
+
+
+
+ {isDragActive
+ ? t("pages.agent.skills.dropzone_active")
+ : t("pages.agent.skills.dropzone_label")}
+
+
+ {isDragActive
+ ? t("pages.agent.skills.dropzone_release")
+ : t("pages.agent.skills.import_constraints")}
+
+
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/origin-badge.tsx b/web/frontend/src/components/agent/skills/origin-badge.tsx
new file mode 100644
index 000000000..0b7bf4391
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/origin-badge.tsx
@@ -0,0 +1,46 @@
+import {
+ IconFileCode,
+ IconFolder,
+ IconSparkles,
+ IconWorld,
+} from "@tabler/icons-react"
+
+import { cn } from "@/lib/utils"
+
+import { getOriginBadgeClasses } from "./origin-utils"
+
+export function OriginBadge({
+ origin,
+ label,
+}: {
+ origin: string
+ label: string
+}) {
+ return (
+
+
+ {label}
+
+ )
+}
+
+export function OriginIcon({ origin }: { origin: string }) {
+ if (origin === "builtin") {
+ return
+ }
+ if (origin === "third_party") {
+ return
+ }
+ if (origin === "manual") {
+ return
+ }
+ if (origin === "all") {
+ return
+ }
+ return
+}
diff --git a/web/frontend/src/components/agent/skills/origin-utils.ts b/web/frontend/src/components/agent/skills/origin-utils.ts
new file mode 100644
index 000000000..6163f7bf7
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/origin-utils.ts
@@ -0,0 +1,86 @@
+import type { TFunction } from "i18next"
+
+import type { SkillSupportItem } from "@/api/skills"
+
+import type { SkillSortOption } from "./types"
+
+const KNOWN_ORIGIN_ORDER = ["builtin", "third_party", "manual"]
+
+export function compareSkills(
+ left: SkillSupportItem,
+ right: SkillSupportItem,
+ sortOrder: SkillSortOption,
+) {
+ if (sortOrder === "source") {
+ const sourceDelta = compareOriginOrder(
+ getSkillOriginKind(left),
+ getSkillOriginKind(right),
+ )
+ if (sourceDelta !== 0) return sourceDelta
+ return left.name.localeCompare(right.name)
+ }
+
+ if (sortOrder === "name-desc") {
+ return right.name.localeCompare(left.name)
+ }
+
+ return left.name.localeCompare(right.name)
+}
+
+export function sortOrigins(origins: string[]) {
+ return [...origins].sort(compareOriginOrder)
+}
+
+export function getSkillOriginKind(skill: SkillSupportItem) {
+ const origin = skill.origin_kind || skill.source
+ return origin === "global" ? "builtin" : origin
+}
+
+export function getOriginLabel(origin: string, t: TFunction) {
+ if (origin === "builtin" || origin === "third_party" || origin === "manual") {
+ return t(`pages.agent.skills.origin.${origin}`)
+ }
+ if (origin === "all") {
+ return t("pages.agent.skills.origin.all")
+ }
+ return origin
+}
+
+export function getOriginAccentClasses(origin: string) {
+ if (origin === "manual") {
+ return "bg-emerald-100 text-emerald-700"
+ }
+ if (origin === "third_party") {
+ return "bg-sky-100 text-sky-700"
+ }
+ if (origin === "builtin") {
+ return "bg-amber-100 text-amber-700"
+ }
+ return "bg-muted text-muted-foreground"
+}
+
+export function getOriginBadgeClasses(origin: string) {
+ if (origin === "manual") {
+ return "bg-emerald-100 text-emerald-700"
+ }
+ if (origin === "third_party") {
+ return "bg-sky-100 text-sky-700"
+ }
+ if (origin === "builtin") {
+ return "bg-amber-100 text-amber-700"
+ }
+ return "bg-muted text-muted-foreground"
+}
+
+function compareOriginOrder(left: string, right: string) {
+ const leftIndex = KNOWN_ORIGIN_ORDER.indexOf(left)
+ const rightIndex = KNOWN_ORIGIN_ORDER.indexOf(right)
+
+ if (leftIndex !== -1 || rightIndex !== -1) {
+ if (leftIndex === -1) return 1
+ if (rightIndex === -1) return -1
+ return leftIndex - rightIndex
+ }
+
+ return left.localeCompare(right)
+}
diff --git a/web/frontend/src/components/agent/skills/page-skeleton.tsx b/web/frontend/src/components/agent/skills/page-skeleton.tsx
new file mode 100644
index 000000000..73df6fcdf
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/page-skeleton.tsx
@@ -0,0 +1,27 @@
+import { Skeleton } from "@/components/ui/skeleton"
+
+export function PageSkeleton() {
+ return (
+
+
+ {[1, 2, 3, 4].map((index) => (
+
+ ))}
+
+
+
+
+
+
+
+ {[1, 2, 3, 4].map((index) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/skill-card.tsx b/web/frontend/src/components/agent/skills/skill-card.tsx
new file mode 100644
index 000000000..15bdc2c63
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/skill-card.tsx
@@ -0,0 +1,84 @@
+import { IconFileInfo, IconTrash } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import type { SkillSupportItem } from "@/api/skills"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+
+interface SkillCardProps {
+ skill: SkillSupportItem
+ onView: () => void
+ onDelete: () => void
+}
+
+export function SkillCard({ skill, onView, onDelete }: SkillCardProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
+
+
+
+ {skill.name}
+
+ {skill.registry_name ? (
+
+ {skill.registry_name}
+
+ ) : null}
+
+
+ {skill.description || t("pages.agent.skills.no_description")}
+
+
+
+
+ {skill.source === "workspace" ? (
+
+ ) : null}
+
+
+
+
+ {skill.registry_url ? (
+
+ {skill.registry_url}
+
+ ) : null}
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/skills-list.tsx b/web/frontend/src/components/agent/skills/skills-list.tsx
new file mode 100644
index 000000000..6a2bb92ed
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/skills-list.tsx
@@ -0,0 +1,86 @@
+import { IconSearch } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import type { SkillSupportItem } from "@/api/skills"
+
+import { OriginBadge } from "./origin-badge"
+import { getOriginLabel } from "./origin-utils"
+import { SkillCard } from "./skill-card"
+import type { SkillGroupSection, SkillLayoutMode } from "./types"
+
+interface SkillsListProps {
+ sortedSkills: SkillSupportItem[]
+ groupedSkills: SkillGroupSection[]
+ layoutMode: SkillLayoutMode
+ sourceFilter: string
+ hasActiveFilters: boolean
+ onViewSkill: (skill: SkillSupportItem) => void
+ onDeleteSkill: (skill: SkillSupportItem) => void
+}
+
+export function SkillsList({
+ sortedSkills,
+ groupedSkills,
+ layoutMode,
+ sourceFilter,
+ hasActiveFilters,
+ onViewSkill,
+ onDeleteSkill,
+}: SkillsListProps) {
+ const { t } = useTranslation()
+
+ if (!sortedSkills.length) {
+ return (
+
+
+
+
+
+ {hasActiveFilters
+ ? t("pages.agent.skills.no_results")
+ : t("pages.agent.skills.empty")}
+
+
+ )
+ }
+
+ if (layoutMode === "grouped" && sourceFilter === "all") {
+ return (
+
+ {groupedSkills.map((section) => (
+
+
+
+
+
+ {section.skills.map((skill) => (
+ onViewSkill(skill)}
+ onDelete={() => onDeleteSkill(skill)}
+ />
+ ))}
+
+
+ ))}
+
+ )
+ }
+
+ return (
+
+ {sortedSkills.map((skill) => (
+ onViewSkill(skill)}
+ onDelete={() => onDeleteSkill(skill)}
+ />
+ ))}
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/skills-page.tsx b/web/frontend/src/components/agent/skills/skills-page.tsx
new file mode 100644
index 000000000..d9b5a7cd1
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/skills-page.tsx
@@ -0,0 +1,160 @@
+import { IconLoader2, IconPlus } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import { PageHeader } from "@/components/page-header"
+import { Button } from "@/components/ui/button"
+
+import { DeleteDialog } from "./delete-dialog"
+import { DetailSheet } from "./detail-sheet"
+import { FilterBar } from "./filter-bar"
+import { ImportDialog } from "./import-dialog"
+import { PageSkeleton } from "./page-skeleton"
+import { SkillsList } from "./skills-list"
+import { Stats } from "./stats"
+import { useSkillsPage } from "./use-skills-page"
+
+export function SkillsPage() {
+ const { t } = useTranslation()
+ const {
+ searchQuery,
+ sourceFilter,
+ sortOrder,
+ layoutMode,
+ detailView,
+ isDragActive,
+ isImportDialogOpen,
+ selectedSkill,
+ skillPendingDelete,
+ availableOrigins,
+ groupedSkills,
+ stats,
+ sortedSkills,
+ hasActiveFilters,
+ importInputRef,
+ selectedSkillDetail,
+ skillsError,
+ skillDetailError,
+ isLoading,
+ isSkillDetailLoading,
+ isImportPending,
+ isDeletePending,
+ setSearchQuery,
+ setSourceFilter,
+ setSortOrder,
+ setLayoutMode,
+ setDetailView,
+ openImportDialog,
+ handleViewSkill,
+ handleRequestDelete,
+ handleConfirmDelete,
+ handleImportClick,
+ handleImportFileChange,
+ handleDropZoneDragEnter,
+ handleDropZoneDragLeave,
+ handleDropZoneDrop,
+ handleDetailSheetOpenChange,
+ handleImportDialogOpenChange,
+ handleDeleteDialogOpenChange,
+ } = useSkillsPage()
+
+ return (
+
+
+
+
+ >
+ }
+ />
+
+
+
+ {isLoading ? (
+
+ ) : skillsError ? (
+
+ {t("pages.agent.load_error")}
+
+ ) : (
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/stats.tsx b/web/frontend/src/components/agent/skills/stats.tsx
new file mode 100644
index 000000000..c718fc3be
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/stats.tsx
@@ -0,0 +1,39 @@
+import { Card, CardContent } from "@/components/ui/card"
+import { cn } from "@/lib/utils"
+
+import { OriginIcon } from "./origin-badge"
+import { getOriginAccentClasses } from "./origin-utils"
+import type { SkillStatItem } from "./types"
+
+export function Stats({ stats }: { stats: SkillStatItem[] }) {
+ return (
+
+ {stats.map((stat) => (
+
+
+
+
+ {stat.label}
+
+
+ {stat.count}
+
+
+
+
+
+
+
+ ))}
+
+ )
+}
diff --git a/web/frontend/src/components/agent/skills/types.ts b/web/frontend/src/components/agent/skills/types.ts
new file mode 100644
index 000000000..44509854c
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/types.ts
@@ -0,0 +1,17 @@
+import type { SkillSupportItem } from "@/api/skills"
+
+export type SkillSortOption = "name-asc" | "name-desc" | "source"
+export type SkillLayoutMode = "grouped" | "grid"
+export type SkillDetailView = "preview" | "raw" | "meta"
+
+export interface SkillGroupSection {
+ origin: string
+ skills: SkillSupportItem[]
+}
+
+export interface SkillStatItem {
+ key: string
+ origin: string
+ label: string
+ count: number
+}
diff --git a/web/frontend/src/components/agent/skills/use-skills-page.ts b/web/frontend/src/components/agent/skills/use-skills-page.ts
new file mode 100644
index 000000000..7cf4a01ad
--- /dev/null
+++ b/web/frontend/src/components/agent/skills/use-skills-page.ts
@@ -0,0 +1,339 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import {
+ type ChangeEvent,
+ type DragEvent,
+ startTransition,
+ useDeferredValue,
+ useMemo,
+ useRef,
+ useState,
+} from "react"
+import { useTranslation } from "react-i18next"
+import { toast } from "sonner"
+
+import {
+ type SkillSupportItem,
+ deleteSkill,
+ getSkill,
+ getSkills,
+ importSkill,
+} from "@/api/skills"
+
+import {
+ compareSkills,
+ getOriginLabel,
+ getSkillOriginKind,
+ sortOrigins,
+} from "./origin-utils"
+import type {
+ SkillDetailView,
+ SkillGroupSection,
+ SkillLayoutMode,
+ SkillSortOption,
+ SkillStatItem,
+} from "./types"
+
+const MAX_IMPORT_FILE_SIZE = 1 << 20
+
+export function useSkillsPage() {
+ const { t } = useTranslation()
+ const queryClient = useQueryClient()
+ const importInputRef = useRef(null)
+ const dragDepthRef = useRef(0)
+
+ const [searchQuery, setSearchQuery] = useState("")
+ const deferredSearchQuery = useDeferredValue(searchQuery)
+ const [sourceFilter, setSourceFilter] = useState("all")
+ const [sortOrder, setSortOrder] = useState("name-asc")
+ const [layoutMode, setLayoutMode] = useState("grouped")
+ const [detailView, setDetailView] = useState("preview")
+ const [isDragActive, setIsDragActive] = useState(false)
+ const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
+ const [selectedSkill, setSelectedSkill] = useState(
+ null,
+ )
+ const [skillPendingDelete, setSkillPendingDelete] =
+ useState(null)
+
+ const skillsQuery = useQuery({
+ queryKey: ["skills"],
+ queryFn: getSkills,
+ })
+
+ const skillDetailQuery = useQuery({
+ queryKey: ["skills", selectedSkill?.name],
+ queryFn: () => getSkill(selectedSkill!.name),
+ enabled: selectedSkill !== null,
+ })
+
+ const importMutation = useMutation({
+ mutationFn: async (file: File) => importSkill(file),
+ onSuccess: (importedSkill) => {
+ toast.success(t("pages.agent.skills.import_success"))
+ startTransition(() => {
+ setIsImportDialogOpen(false)
+ setDetailView("preview")
+ if (importedSkill.name) {
+ setSelectedSkill({
+ name: importedSkill.name,
+ path: importedSkill.path ?? "",
+ source: importedSkill.source ?? "workspace",
+ description: importedSkill.description ?? "",
+ origin_kind: importedSkill.origin_kind ?? "manual",
+ registry_name: importedSkill.registry_name,
+ registry_url: importedSkill.registry_url,
+ installed_version: importedSkill.installed_version,
+ installed_at: importedSkill.installed_at,
+ })
+ }
+ })
+ void queryClient.invalidateQueries({ queryKey: ["skills"] })
+ },
+ onError: (err) => {
+ toast.error(
+ err instanceof Error
+ ? err.message
+ : t("pages.agent.skills.import_error"),
+ )
+ },
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: async (name: string) => deleteSkill(name),
+ onSuccess: (_, deletedName) => {
+ toast.success(t("pages.agent.skills.delete_success"))
+ setSkillPendingDelete(null)
+ if (
+ selectedSkill?.name === deletedName &&
+ selectedSkill.source === "workspace"
+ ) {
+ setSelectedSkill(null)
+ }
+ void queryClient.invalidateQueries({ queryKey: ["skills"] })
+ },
+ onError: (err) => {
+ toast.error(
+ err instanceof Error
+ ? err.message
+ : t("pages.agent.skills.delete_error"),
+ )
+ },
+ })
+
+ const allSkills = useMemo(
+ () => skillsQuery.data?.skills ?? [],
+ [skillsQuery.data?.skills],
+ )
+ const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase()
+
+ const availableOrigins = useMemo(
+ () =>
+ sortOrigins([
+ ...new Set(allSkills.map((skill) => getSkillOriginKind(skill))),
+ ]),
+ [allSkills],
+ )
+
+ const filteredSkills = useMemo(() => {
+ return allSkills.filter((skill) => {
+ const matchesSource =
+ sourceFilter === "all"
+ ? true
+ : getSkillOriginKind(skill) === sourceFilter
+ if (!matchesSource) return false
+ if (normalizedSearchQuery === "") return true
+
+ const searchTarget =
+ `${skill.name} ${skill.description} ${skill.registry_name ?? ""}`.toLowerCase()
+ return searchTarget.includes(normalizedSearchQuery)
+ })
+ }, [allSkills, normalizedSearchQuery, sourceFilter])
+
+ const sortedSkills = useMemo(
+ () =>
+ [...filteredSkills].sort((left, right) =>
+ compareSkills(left, right, sortOrder),
+ ),
+ [filteredSkills, sortOrder],
+ )
+
+ const groupedSkills = useMemo(
+ () =>
+ availableOrigins
+ .map((origin) => ({
+ origin,
+ skills: sortedSkills.filter(
+ (skill) => getSkillOriginKind(skill) === origin,
+ ),
+ }))
+ .filter((section) => section.skills.length > 0),
+ [availableOrigins, sortedSkills],
+ )
+
+ const stats = useMemo(
+ () => [
+ {
+ key: "all",
+ origin: "all",
+ label: t("pages.agent.skills.summary.total"),
+ count: allSkills.length,
+ },
+ ...availableOrigins.map((origin) => ({
+ key: origin,
+ origin,
+ label: getOriginLabel(origin, t),
+ count: allSkills.filter((skill) => getSkillOriginKind(skill) === origin)
+ .length,
+ })),
+ ],
+ [allSkills, availableOrigins, t],
+ )
+
+ const hasActiveFilters =
+ normalizedSearchQuery !== "" || sourceFilter !== "all"
+
+ const handleImportClick = () => {
+ importInputRef.current?.click()
+ }
+
+ const handleViewSkill = (skill: SkillSupportItem) => {
+ setDetailView("preview")
+ setSelectedSkill(skill)
+ }
+
+ const handleRequestDelete = (skill: SkillSupportItem) => {
+ setSkillPendingDelete(skill)
+ }
+
+ const handleConfirmDelete = () => {
+ if (skillPendingDelete) {
+ deleteMutation.mutate(skillPendingDelete.name)
+ }
+ }
+
+ const handleDetailSheetOpenChange = (open: boolean) => {
+ if (!open) {
+ setSelectedSkill(null)
+ }
+ }
+
+ const handleImportDialogOpenChange = (open: boolean) => {
+ if (!importMutation.isPending) {
+ setIsImportDialogOpen(open)
+ }
+ }
+
+ const handleDeleteDialogOpenChange = (open: boolean) => {
+ if (!open) {
+ setSkillPendingDelete(null)
+ }
+ }
+
+ const validateImportFile = (file: File) => {
+ const fileName = file.name.toLowerCase()
+ const isMarkdownFile =
+ fileName.endsWith(".md") ||
+ file.type === "text/markdown" ||
+ file.type === "text/plain" ||
+ file.type === ""
+ const isZipFile =
+ fileName.endsWith(".zip") ||
+ file.type === "application/zip" ||
+ file.type === "application/x-zip-compressed"
+
+ if (!isMarkdownFile && !isZipFile) {
+ return t("pages.agent.skills.import_invalid_type")
+ }
+
+ if (file.size > MAX_IMPORT_FILE_SIZE) {
+ return t("pages.agent.skills.import_invalid_size")
+ }
+
+ return null
+ }
+
+ const handleImportFile = (file: File) => {
+ const validationMessage = validateImportFile(file)
+ if (validationMessage) {
+ toast.error(validationMessage)
+ return
+ }
+ importMutation.mutate(file)
+ }
+
+ const handleImportFileChange = (event: ChangeEvent) => {
+ const file = event.target.files?.[0]
+ if (!file) return
+ handleImportFile(file)
+ event.target.value = ""
+ }
+
+ const resetDragState = () => {
+ dragDepthRef.current = 0
+ setIsDragActive(false)
+ }
+
+ const handleDropZoneDragEnter = (event: DragEvent) => {
+ event.preventDefault()
+ dragDepthRef.current += 1
+ setIsDragActive(true)
+ }
+
+ const handleDropZoneDragLeave = (event: DragEvent) => {
+ event.preventDefault()
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
+ if (dragDepthRef.current === 0) {
+ setIsDragActive(false)
+ }
+ }
+
+ const handleDropZoneDrop = (event: DragEvent) => {
+ event.preventDefault()
+ const file = event.dataTransfer.files?.[0]
+ resetDragState()
+ if (!file) return
+ handleImportFile(file)
+ }
+
+ return {
+ searchQuery,
+ sourceFilter,
+ sortOrder,
+ layoutMode,
+ detailView,
+ isDragActive,
+ isImportDialogOpen,
+ selectedSkill,
+ skillPendingDelete,
+ availableOrigins,
+ groupedSkills,
+ stats,
+ sortedSkills,
+ hasActiveFilters,
+ importInputRef,
+ selectedSkillDetail: skillDetailQuery.data,
+ skillsError: skillsQuery.error,
+ skillDetailError: skillDetailQuery.error,
+ isLoading: skillsQuery.isLoading,
+ isSkillDetailLoading: skillDetailQuery.isLoading,
+ isImportPending: importMutation.isPending,
+ isDeletePending: deleteMutation.isPending,
+ setSearchQuery,
+ setSourceFilter,
+ setSortOrder,
+ setLayoutMode,
+ setDetailView,
+ openImportDialog: () => setIsImportDialogOpen(true),
+ handleViewSkill,
+ handleRequestDelete,
+ handleConfirmDelete,
+ handleImportClick,
+ handleImportFileChange,
+ handleDropZoneDragEnter,
+ handleDropZoneDragLeave,
+ handleDropZoneDrop,
+ handleDetailSheetOpenChange,
+ handleImportDialogOpenChange,
+ handleDeleteDialogOpenChange,
+ }
+}
diff --git a/web/frontend/src/components/agent/tools/tool-library-tab.tsx b/web/frontend/src/components/agent/tools/tool-library-tab.tsx
new file mode 100644
index 000000000..6bbfeb091
--- /dev/null
+++ b/web/frontend/src/components/agent/tools/tool-library-tab.tsx
@@ -0,0 +1,270 @@
+import { IconSearch, IconSettings } from "@tabler/icons-react"
+import { useTranslation } from "react-i18next"
+
+import type { ToolSupportItem } from "@/api/tools"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Switch } from "@/components/ui/switch"
+import { cn } from "@/lib/utils"
+
+import { ToolStatusBadge } from "./tool-status-badge"
+import type { GroupedTools, ToolStatusFilter } from "./types"
+
+interface ToolLibraryTabProps {
+ allTools: ToolSupportItem[]
+ groupedTools: GroupedTools
+ totalFilteredCount: number
+ searchQuery: string
+ statusFilter: ToolStatusFilter
+ isLoading: boolean
+ hasError: boolean
+ pendingToolName: string | null
+ onSearchQueryChange: (value: string) => void
+ onStatusFilterChange: (value: ToolStatusFilter) => void
+ onOpenWebSearchSettings: () => void
+ onToggleTool: (name: string, enabled: boolean) => void
+}
+
+export function ToolLibraryTab({
+ allTools,
+ groupedTools,
+ totalFilteredCount,
+ searchQuery,
+ statusFilter,
+ isLoading,
+ hasError,
+ pendingToolName,
+ onSearchQueryChange,
+ onStatusFilterChange,
+ onOpenWebSearchSettings,
+ onToggleTool,
+}: ToolLibraryTabProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
+ {t("pages.agent.skills.marketplace_results_title", { + query: submittedQuery, + count: marketResults.length, + })} +
+ + {t("pages.agent.skills.marketplace_results_hint")} + ++ {t("pages.agent.skills.marketplace_title", { + defaultValue: "Discover Skills", + })} +
++ {t("pages.agent.skills.marketplace_description")} +
+
+ {selectedSkillDetail.content}
+
+ + {isDragActive + ? t("pages.agent.skills.dropzone_release") + : t("pages.agent.skills.import_constraints")} +
+
@@ -162,22 +164,32 @@ Alternatively, download the binary for your platform from the [GitHub Releases](
### Build from source (for development)
+Prerequisites:
+
+- Go 1.25+
+- Node.js 22+ and pnpm 10.33.0+ for Web UI / launcher builds
+
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
-# Build core binary
+# Install frontend dependencies
+(cd web/frontend && pnpm install --frozen-lockfile)
+
+# Build the core binary for the current platform
make build
-# Build Web UI Launcher (required for WebUI mode)
+# Build the Web UI Launcher (required for WebUI mode)
make build-launcher
-# Build for multiple platforms
+# Build core binaries for all Makefile-managed platforms
make build-all
-# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
+# Build for Raspberry Pi Zero 2 W
+# 32-bit: make build-linux-arm
+# 64-bit: make build-linux-arm64
make build-pi-zero
# Build and install
@@ -213,7 +225,7 @@ picoclaw-launcher
-



-**Option 2: APK Install (coming soon)**
-
-A standalone Android APK with built-in WebUI is in development. Stay tuned!
-
-
+
+
+
+
+
-> **[Liste de compatibilité matérielle](docs/fr/hardware-compatibility.md)** — Voir toutes les cartes testées, du RISC-V à $5 au Raspberry Pi en passant par les téléphones Android. Votre carte n'est pas listée ? Soumettez une PR !
+> **[Liste de compatibilité matérielle](../guides/hardware-compatibility.fr.md)** — Voir toutes les cartes testées, du RISC-V à $5 au Raspberry Pi en passant par les téléphones Android. Votre carte n'est pas listée ? Soumettez une PR !






+
+




Pour les environnements minimaux où seul le binaire principal `picoclaw` est disponible (sans Launcher UI), vous pouvez tout configurer via la ligne de commande et un fichier de configuration JSON.
@@ -433,7 +437,7 @@ PicoClaw supporte plus de 30 providers LLM via la configuration `model_list`. Ut
}
```
-Pour les détails complets de configuration des providers, voir [Providers & Models](docs/fr/providers.md).
+Pour les détails complets de configuration des providers, voir [Providers & Models](../guides/providers.fr.md).
-
-
-
-
+
diff --git a/README.id.md b/docs/project/README.id.md
similarity index 79%
rename from README.id.md
rename to docs/project/README.id.md
index d88f5eb32..49c64e74c 100644
--- a/README.id.md
+++ b/docs/project/README.id.md
@@ -1,5 +1,5 @@