diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 000000000..ca4edc2ab --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,167 @@ +name: Nightly Build + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + generate-version: + name: Generate Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Generate version + id: version + run: | + DATE=$(date -u +%Y%m%d) + SHA=$(git rev-parse --short=8 HEAD) + BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true) + if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then + VERSION="nightly-${DATE}-${SHA}" + else + VERSION="${BASE_VERSION}-nightly-${DATE}-${SHA}" + fi + TAG="nightly-${DATE}-${SHA}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + build: + name: Build + runs-on: ubuntu-latest + needs: generate-version + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Build + env: + VERSION: ${{ needs.generate-version.outputs.version }} + run: make build-all VERSION="$VERSION" + + - name: Upload artifacts + uses: actions/upload-artifact@v6 + with: + name: picoclaw-binaries + path: build + + build-docker: + name: Build Docker + runs-on: ubuntu-latest + needs: generate-version + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=${{ needs.generate-version.outputs.tag }} + type=raw,value=${{ needs.generate-version.outputs.version }} + type=raw,value=nightly + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64,linux/riscv64 + provenance: false + + release: + name: Release + runs-on: ubuntu-latest + needs: [generate-version, build, build-docker] + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download artifacts + uses: actions/download-artifact@v6 + with: + name: picoclaw-binaries + path: ./build + + - name: Create release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ needs.generate-version.outputs.tag }}" + TITLE="${{ needs.generate-version.outputs.version }}" + NOTES=$'Nightly build for **${{ needs.generate-version.outputs.version }}**\n\nThis is an automated build and may be unstable. Use with caution.' + + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists, updating metadata and assets..." + gh release edit "$TAG" \ + --title "$TITLE" \ + --notes "$NOTES" \ + --prerelease + gh release upload "$TAG" build/* --clobber + else + echo "Creating new release $TAG..." + gh release create "$TAG" \ + --title "$TITLE" \ + --notes "$NOTES" \ + --target "${{ github.sha }}" \ + --prerelease \ + build/* + fi + + echo "Updating rolling 'nightly' release..." + gh release delete nightly --cleanup-tag -y >/dev/null 2>&1 || true + sleep 2 + gh release create nightly \ + --title "Nightly Build" \ + --notes "$NOTES" \ + --target "${{ github.sha }}" \ + --prerelease \ + build/* + + echo "Cleaning up old nightly releases (keeping only the most recent)..." + gh release list --limit 100 --json tagName -q '.[].tagName | select(startswith("nightly-"))' | tail -n +2 | while read -r old_tag; do + if [ -n "$old_tag" ] && [ "$old_tag" != "$TAG" ]; then + echo "Deleting old nightly release: $old_tag" + gh release delete "$old_tag" --cleanup-tag -y || true + fi + done diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d531d106b..7bc59bd2d 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -32,9 +32,13 @@ builds: - riscv64 - loong64 - arm + - s390x + - mipsle goarm: - "6" - "7" + gomips: + - softfloat main: ./cmd/picoclaw ignore: - goos: windows @@ -59,9 +63,13 @@ builds: - riscv64 - loong64 - arm + - s390x + - mipsle goarm: - "6" - "7" + gomips: + - softfloat main: ./cmd/picoclaw-launcher ignore: - goos: windows @@ -86,9 +94,13 @@ builds: - riscv64 - loong64 - arm + - s390x + - mipsle goarm: - "6" - "7" + gomips: + - softfloat main: ./cmd/picoclaw-launcher-tui ignore: - goos: windows diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index d9263462e..fe4de8ecc 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -30,7 +30,7 @@ func NewPicoclawCommand() *cobra.Command { cmd := &cobra.Command{ Use: "picoclaw", Short: short, - Example: "picoclaw list", + Example: "picoclaw version", } cmd.AddCommand( diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index f682a7ffe..f1c275806 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -632,15 +632,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } route, agent, routeErr := al.resolveMessageRoute(msg) - - // Commands are checked before requiring a successful route. - // Global commands (/help, /show, /switch) work even when routing fails; - // context-dependent commands check their own Runtime fields and report - // "unavailable" when the required capability is nil. - if response, handled := al.handleCommand(ctx, msg, agent); handled { - return response, nil - } - if routeErr != nil { return "", routeErr } @@ -666,7 +657,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) "route_channel": route.Channel, }) - return al.runAgentLoop(ctx, agent, processOptions{ + opts := processOptions{ SessionKey: sessionKey, Channel: msg.Channel, ChatID: msg.ChatID, @@ -675,7 +666,15 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) DefaultResponse: defaultResponse, EnableSummary: true, SendResponse: false, - }) + } + + // context-dependent commands check their own Runtime fields and report + // "unavailable" when the required capability is nil. + if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled { + return response, nil + } + + return al.runAgentLoop(ctx, agent, opts) } func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { @@ -1543,10 +1542,20 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { return } + const ( + maxSummarizationMessages = 10 + llmMaxRetries = 3 + llmTemperature = 0.3 + fallbackMaxContentLength = 200 + ) + // Multi-Part Summarization var finalSummary string - if len(validMessages) > 10 { + if len(validMessages) > maxSummarizationMessages { mid := len(validMessages) / 2 + + mid = al.findNearestUserMessage(validMessages, mid) + part1 := validMessages[:mid] part2 := validMessages[mid:] @@ -1558,18 +1567,9 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { s1, s2, ) - resp, err := agent.Provider.Chat( - ctx, - []providers.Message{{Role: "user", Content: mergePrompt}}, - nil, - agent.Model, - map[string]any{ - "max_tokens": 1024, - "temperature": 0.3, - "prompt_cache_key": agent.ID, - }, - ) - if err == nil { + + resp, err := al.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries) + if err == nil && resp.Content != "" { finalSummary = resp.Content } else { finalSummary = s1 + " " + s2 @@ -1589,6 +1589,68 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { } } +// findNearestUserMessage finds the nearest user message to the given index. +// It searches backward first, then forward if no user message is found. +func (al *AgentLoop) findNearestUserMessage(messages []providers.Message, mid int) int { + originalMid := mid + + for mid > 0 && messages[mid].Role != "user" { + mid-- + } + + if messages[mid].Role == "user" { + return mid + } + + mid = originalMid + for mid < len(messages) && messages[mid].Role != "user" { + mid++ + } + + if mid < len(messages) { + return mid + } + + return originalMid +} + +// retryLLMCall calls the LLM with retry logic. +func (al *AgentLoop) retryLLMCall( + ctx context.Context, + agent *AgentInstance, + prompt string, + maxRetries int, +) (*providers.LLMResponse, error) { + const ( + llmTemperature = 0.3 + ) + + var resp *providers.LLMResponse + var err error + + for attempt := 0; attempt < maxRetries; attempt++ { + resp, err = agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: prompt}}, + nil, + agent.Model, + map[string]any{ + "max_tokens": agent.MaxTokens, + "temperature": llmTemperature, + "prompt_cache_key": agent.ID, + }, + ) + if err == nil && resp != nil && resp.Content != "" { + return resp, nil + } + if attempt < maxRetries-1 { + time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond) + } + } + + return resp, err +} + // summarizeBatch summarizes a batch of messages. func (al *AgentLoop) summarizeBatch( ctx context.Context, @@ -1596,6 +1658,13 @@ func (al *AgentLoop) summarizeBatch( batch []providers.Message, existingSummary string, ) (string, error) { + const ( + llmMaxRetries = 3 + llmTemperature = 0.3 + fallbackMinContentLength = 200 + fallbackMaxContentPercent = 10 + ) + var sb strings.Builder sb.WriteString( "Provide a concise summary of this conversation segment, preserving core context and key points.\n", @@ -1611,21 +1680,40 @@ func (al *AgentLoop) summarizeBatch( } prompt := sb.String() - response, err := agent.Provider.Chat( - ctx, - []providers.Message{{Role: "user", Content: prompt}}, - nil, - agent.Model, - map[string]any{ - "max_tokens": 1024, - "temperature": 0.3, - "prompt_cache_key": agent.ID, - }, - ) - if err != nil { - return "", err + response, err := al.retryLLMCall(ctx, agent, prompt, llmMaxRetries) + if err == nil && response.Content != "" { + return strings.TrimSpace(response.Content), nil } - return response.Content, nil + + var fallback strings.Builder + fallback.WriteString("Conversation summary: ") + for i, m := range batch { + if i > 0 { + fallback.WriteString(" | ") + } + content := strings.TrimSpace(m.Content) + runes := []rune(content) + if len(runes) == 0 { + fallback.WriteString(fmt.Sprintf("%s: ", m.Role)) + continue + } + + keepLength := len(runes) * fallbackMaxContentPercent / 100 + if keepLength < fallbackMinContentLength { + keepLength = fallbackMinContentLength + } + + if keepLength > len(runes) { + keepLength = len(runes) + } + + content = string(runes[:keepLength]) + if keepLength < len(runes) { + content += "..." + } + fallback.WriteString(fmt.Sprintf("%s: %s", m.Role, content)) + } + return fallback.String(), nil } // estimateTokens estimates the number of tokens in a message list. @@ -1644,6 +1732,7 @@ func (al *AgentLoop) handleCommand( ctx context.Context, msg bus.InboundMessage, agent *AgentInstance, + opts *processOptions, ) (string, bool) { if !commands.HasCommandPrefix(msg.Content) { return "", false @@ -1653,7 +1742,7 @@ func (al *AgentLoop) handleCommand( return "", false } - rt := al.buildCommandsRuntime(agent) + rt := al.buildCommandsRuntime(agent, opts) executor := commands.NewExecutor(al.cmdRegistry, rt) var commandReply string @@ -1682,7 +1771,7 @@ func (al *AgentLoop) handleCommand( } } -func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance) *commands.Runtime { +func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime { rt := &commands.Runtime{ Config: al.cfg, ListAgentIDs: al.registry.ListAgentIDs, @@ -1712,6 +1801,20 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance) *commands.Runtim agent.Model = value return oldModel, nil } + + rt.ClearHistory = func() error { + if opts == nil { + return fmt.Errorf("process options not available") + } + if agent.Sessions == nil { + return fmt.Errorf("sessions not initialized for agent") + } + + agent.Sessions.SetHistory(opts.SessionKey, make([]providers.Message, 0)) + agent.Sessions.SetSummary(opts.SessionKey, "") + agent.Sessions.Save(opts.SessionKey) + return nil + } } return rt } diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index a36dd3eba..aed6a1874 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -12,5 +12,6 @@ func BuiltinDefinitions() []Definition { listCommand(), switchCommand(), checkCommand(), + clearCommand(), } } diff --git a/pkg/commands/cmd_clear.go b/pkg/commands/cmd_clear.go new file mode 100644 index 000000000..f0951eb3b --- /dev/null +++ b/pkg/commands/cmd_clear.go @@ -0,0 +1,20 @@ +package commands + +import "context" + +func clearCommand() Definition { + return Definition{ + Name: "clear", + Description: "Clear the chat history", + Usage: "/clear", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ClearHistory == nil { + return req.Reply(unavailableMsg) + } + if err := rt.ClearHistory(); err != nil { + return req.Reply("Failed to clear chat history: " + err.Error()) + } + return req.Reply("Chat history cleared!") + }, + } +} diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 227d495f4..037184686 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -13,4 +13,5 @@ type Runtime struct { GetEnabledChannels func() []string SwitchModel func(value string) (oldModel string, err error) SwitchChannel func(value string) error + ClearHistory func() error }