Add session management commands: /status, /compact, /new

This commit is contained in:
maper 2026-04-13 04:52:17 +08:00
parent 6d03791929
commit 919933a45c
14 changed files with 966 additions and 27 deletions

View file

@ -0,0 +1,241 @@
# PicoClaw 上下文管理功能调研报告
## 📊 OpenClaw 原有功能 vs PicoClaw 当前状态
### OpenClaw 核心命令PicoClaw 缺失)
| 命令 | 功能 | PicoClaw 状态 |
|------|------|---------------|
| `/status` | 查看会话状态模型、Token使用量、成本 | ❌ 未实现 |
| `/compact` | 手动压缩上下文(摘要化历史) | ❌ 未实现 |
| `/new``/reset` | 开始新会话重置session | ❌ 未实现 |
| `/usage` | 查看Token消耗详情 | ❌ 未实现 |
| `/context list` | 查看已加载的上下文 | ❌ 未实现 |
---
## 🔍 OpenClaw /status 命令详解
### 功能
显示当前会话的详细状态信息
### 输出内容
| 信息 | 说明 |
|------|------|
| 当前模型 | 正在使用的AI模型 |
| 上下文使用量 | 已消耗的Token数量 |
| 最后响应Token | 上一轮对话的Token |
| 预估成本 | 本次会话费用仅API Key用户可见 |
| Gateway状态 | 网关是否繁忙 |
### 使用场景
- 感觉对话变慢时检查 `/status`
- 上下文超过50%时考虑 `/new`
- 定期检查成本,避免超支
---
## 🔍 OpenClaw /compact 命令详解
### 功能
将历史对话压缩成摘要,保留最近消息完整
### 命令格式
```
/compact # 默认压缩
/compact 保留代码讨论 # 带指令的压缩
```
### 工作原理
1. 将旧的对话内容摘要化
2. 保留最近的消息完整
3. 摘要存储在transcript中
4. 显著减少Token消耗
### 使用场景
- 长任务开始前
- 上下文接近上限
- 保留重要上下文但省Token
### /compact vs /new 对比
| 命令 | 效果 | 适用场景 |
|------|------|---------|
| `/new` | 完全重置,清空历史 | 切换任务 |
| `/compact` | 压缩历史,保留摘要 | 继续当前任务但省Token |
---
## 📁 PicoClaw 现有上下文管理
### 已实现的功能
#### 1. 自动压缩 (Auto-compaction)
- **触发条件**: 上下文超过阈值
- **配置项**: `summarize_message_threshold`, `summarize_token_percent`
- **实现位置**: `pkg/agent/context_legacy.go`
#### 2. 强制压缩 (Force Compression)
- 当上下文超限时丢弃最旧的50%消息
- 保留完整的对话轮次Turn
- 创建压缩说明摘要
#### 3. 上下文管理器
PicoClaw 支持两种上下文管理器:
- `legacy` (默认) - 基于摘要的压缩
- `seahorse` - 高级向量检索
---
## 🎯 PicoClaw 需要补充的命令
### 1. /status 命令
**功能**: 显示当前会话状态
```go
// 实现建议
func statusCommand() *Definition {
return &Definition{
Name: "status",
Aliases: []string{"s"},
Description: "Show session status (model, tokens, cost)",
Handler: func(req *Request) error {
// 获取当前session信息
session := getSession(req.SessionKey)
return req.Replyf(
"📊 Session Status\n\n"+
"Model: %s\n"+
"Context: %d / %d tokens (%.1f%%)\n"+
"Messages: %d\n"+
"Compactions: %d",
session.Model,
session.UsedTokens,
session.MaxTokens,
session.UsagePercent,
session.MessageCount,
session.CompactionCount,
)
},
}
}
```
### 2. /compact 命令
**功能**: 手动触发上下文压缩
```go
// 实现建议
func compactCommand() *Definition {
return &Definition{
Name: "compact",
Aliases: []string{"c"},
Description: "Compact session context (summarize history)",
Handler: func(req *Request) error {
// 调用现有的Compact功能
agent := getAgent(req.AgentID)
agent.CompactContext(req.SessionKey, req.Text)
return req.Reply("✅ Context compacted successfully")
},
}
}
```
### 3. /new 命令
**功能**: 开始新的会话
```go
// 实现建议
func newCommand() *Definition {
return &Definition{
Name: "new",
Aliases: []string{"reset"},
Description: "Start a new session (reset conversation)",
Handler: func(req *Request) error {
// 创建新的session
agent := getAgent(req.AgentID)
agent.NewSession(req.SessionKey)
return req.Reply("🆕 New session started. Previous context has been archived.")
},
}
}
```
---
## 📂 相关代码文件
### PicoClaw 核心文件
- `pkg/agent/context_manager.go` - 上下文管理器接口
- `pkg/agent/context_legacy.go` - 现有压缩实现
- `pkg/session/manager.go` - 会话管理
- `pkg/commands/builtin.go` - 内置命令列表
- `pkg/commands/cmd_*.go` - 各命令实现
### Telegram 集成
- `pkg/channels/telegram/telegram.go` - Telegram通道
- `pkg/channels/telegram/commands.go` - Telegram命令处理需新增
---
## 🛠️ 实现计划
### 第一阶段:基础命令
1. 实现 `/status` 命令
2. 实现 `/new` 命令
3. 实现 `/reset` 命令(别名)
### 第二阶段:压缩命令
4. 实现 `/compact` 命令
5. 添加压缩计数追踪
6. 优化压缩摘要质量
### 第三阶段:增强功能
7. 实现 `/usage` 命令
8. 实现 `/context` 命令
9. 添加成本计算
---
## 📝 配置项建议
```json
{
"agents": {
"defaults": {
"summarize_message_threshold": 20,
"summarize_token_percent": 75
}
},
"commands": {
"status": {
"show_cost": true,
"show_context_percent": true
},
"compact": {
"default_summary": "会话摘要"
}
}
}
```
---
## 🔗 参考资源
- [OpenClaw Usage Guide](https://openclaw-ai.online/usage/)
- [OpenClaw Session Management Deep Dive](https://github.com/openclaw/openclaw/blob/main/docs/reference/session-management-compaction.md)
- [Compaction Documentation](https://openclaw.dog/docs/concepts/compaction/)
---
## 📌 下一步行动
1. 在 `pkg/commands/` 目录下创建新命令文件
2. 在 `builtin.go` 中注册新命令
3. 确保 Telegram 通道正确路由这些命令
4. 添加测试用例
5. 更新文档
---
*调研完成时间: 2026-04-12*

150
PICO_STARTUP_GUIDE.md Normal file
View file

@ -0,0 +1,150 @@
# PicoClaw 启动方式指南
## 🎯 推荐方式Web UI Launcher桌面用户
这是官方推荐的桌面用户使用方式:
### 方式一:双击启动(推荐)
1. 下载 PicoClaw
- 访问 https://picoclaw.io
- 下载对应平台的版本
2. 双击 `picoclaw-launcher.exe`Windows`picoclaw-launcher`macOS/Linux
- 浏览器会自动打开 http://localhost:18800
3. 在 Web UI 中配置:
- **Provider** 页面:配置 MiniMax API Key
- **Channel** 页面:配置 Telegram Bot Token
- **Gateway** 页面:启动服务
### 方式二:命令行启动 Web UI
```bash
cd 下载目录
picoclaw-launcher
# 在浏览器打开 http://localhost:18800
```
### 远程访问Docker/虚拟机)
```bash
picoclaw-launcher -public
```
---
## 💻 命令行模式(服务器/无头环境)
### 交互式对话
```bash
picoclaw agent
# 输入消息与 AI 对话
```
### 单次消息模式
```bash
picoclaw agent -m "你好"
```
### 启动 Gateway连接聊天平台
```bash
picoclaw gateway
```
---
## ⚙️ 配置文件位置
### 默认配置目录
- **Windows**: `C:\Users\你的用户名\.picoclaw\`
- **macOS/Linux**: `~/.picoclaw/`
### 配置文件
- `config.json` - 主配置文件
- `workspace/` - 工作区目录
### 初始化配置
如果需要重新初始化配置:
```bash
picoclaw onboard
```
---
## 🔧 问题排查
### Web UI 中模型为空
如果 Web UI Launcher 中看不到配置的模型:
1. 检查配置文件:`C:\Users\用户名\.picoclaw\config.json`
2. 确保 `model_list` 中包含模型配置
3. 确保 `agents.defaults.model_name` 设置了默认模型
### MiniMax Token Plan 配置示例
```json
{
"model_list": [
{
"model_name": "MiniMax-M2.7",
"model": "minimax/MiniMax-M2.7",
"api_base": "https://api.minimaxi.com/v1",
"api_keys": ["你的Token Plan Key"]
}
],
"agents": {
"defaults": {
"model_name": "MiniMax-M2.7"
}
}
}
```
### Telegram 连接配置
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "你的Bot Token",
"allow_from": ["你的User ID"],
"proxy": "http://127.0.0.1:代理端口"
}
}
}
```
---
## 🚀 官方资源
- **官网**: https://picoclaw.io
- **文档**: https://docs.picoclaw.io
- **GitHub**: https://github.com/sipeed/picoclaw
---
## 💡 最佳实践
1. **桌面用户**: 使用 Web UI Launcher
2. **服务器/无头环境**: 使用命令行模式
3. **开发测试**: 使用 `picoclaw agent -m` 单次测试
4. **生产环境**: 使用 `picoclaw gateway` 后台运行
---
## 🔒 安全提示
- 禁用 `deny_patterns`PicoClaw 可以执行任何命令
- 确保 Telegram Bot 的 `allow_from` 设置了你的 User ID
- 妥善保管 API Key不要泄露

View file

@ -36,10 +36,15 @@ func (m *legacyContextManager) Assemble(_ context.Context, req *AssembleRequest)
} }
func (m *legacyContextManager) Compact(_ context.Context, req *CompactRequest) error { func (m *legacyContextManager) Compact(_ context.Context, req *CompactRequest) error {
if req == nil {
return nil
}
switch req.Reason { switch req.Reason {
case ContextCompressReasonProactive, ContextCompressReasonRetry: case ContextCompressReasonProactive, ContextCompressReasonRetry:
// Sync emergency compression — budget exceeded. // Sync emergency compression — budget exceeded.
if result, ok := m.forceCompression(req.SessionKey); ok { if result, ok := m.forceCompression(req.SessionKey); ok {
m.al.incrementCompactionCount(req.SessionKey)
m.al.emitEvent( m.al.emitEvent(
EventKindContextCompress, EventKindContextCompress,
m.al.newTurnEventScope("", req.SessionKey).meta(0, "forceCompression", "turn.context.compress"), m.al.newTurnEventScope("", req.SessionKey).meta(0, "forceCompression", "turn.context.compress"),
@ -51,6 +56,14 @@ func (m *legacyContextManager) Compact(_ context.Context, req *CompactRequest) e
) )
} }
case ContextCompressReasonSummarize: case ContextCompressReasonSummarize:
if req.Manual {
agent := m.al.registry.GetDefaultAgent()
if agent == nil {
return nil
}
_, _, err := m.summarizeSessionWithOptions(agent, req.SessionKey, req.Instructions, true)
return err
}
m.maybeSummarize(req.SessionKey) m.maybeSummarize(req.SessionKey)
} }
return nil return nil
@ -87,7 +100,7 @@ func (m *legacyContextManager) maybeSummarize(sessionKey string) {
} }
}() }()
logger.Debug("Memory threshold reached. Optimizing conversation history...") logger.Debug("Memory threshold reached. Optimizing conversation history...")
m.summarizeSession(agent, sessionKey) _, _, _ = m.summarizeSessionWithOptions(agent, sessionKey, "", false)
}() }()
} }
} }
@ -159,19 +172,32 @@ func (m *legacyContextManager) forceCompression(sessionKey string) (compressionR
} }
func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey string) { func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey string) {
_, _, _ = m.summarizeSessionWithOptions(agent, sessionKey, "", false)
}
func (m *legacyContextManager) summarizeSessionWithOptions(
agent *AgentInstance,
sessionKey string,
instructions string,
manual bool,
) (SessionSummarizePayload, bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel() defer cancel()
history := agent.Sessions.GetHistory(sessionKey) history := agent.Sessions.GetHistory(sessionKey)
summary := agent.Sessions.GetSummary(sessionKey) summary := agent.Sessions.GetSummary(sessionKey)
if len(history) <= 4 { keepLast := 4
return if manual {
keepLast = 2
}
if len(history) <= keepLast {
return SessionSummarizePayload{}, false, nil
} }
safeCut := findSafeBoundary(history, len(history)-4) safeCut := findSafeBoundary(history, len(history)-keepLast)
if safeCut <= 0 { if safeCut <= 0 {
return return SessionSummarizePayload{}, false, nil
} }
keepCount := len(history) - safeCut keepCount := len(history) - safeCut
toSummarize := history[:safeCut] toSummarize := history[:safeCut]
@ -193,7 +219,7 @@ func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey
} }
if len(validMessages) == 0 { if len(validMessages) == 0 {
return return SessionSummarizePayload{}, false, nil
} }
const ( const (
@ -209,13 +235,16 @@ func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey
part1 := validMessages[:mid] part1 := validMessages[:mid]
part2 := validMessages[mid:] part2 := validMessages[mid:]
s1, _ := m.summarizeBatch(ctx, agent, part1, "") s1, _ := m.summarizeBatch(ctx, agent, part1, "", instructions)
s2, _ := m.summarizeBatch(ctx, agent, part2, "") s2, _ := m.summarizeBatch(ctx, agent, part2, "", instructions)
mergePrompt := fmt.Sprintf( mergePrompt := fmt.Sprintf(
"Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
s1, s2, s1, s2,
) )
if instructions != "" {
mergePrompt += "\n\nAdditional focus for manual compaction: " + instructions
}
resp, err := m.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries) resp, err := m.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries)
if err == nil && resp.Content != "" { if err == nil && resp.Content != "" {
@ -224,28 +253,34 @@ func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey
finalSummary = s1 + " " + s2 finalSummary = s1 + " " + s2
} }
} else { } else {
finalSummary, _ = m.summarizeBatch(ctx, agent, validMessages, summary) finalSummary, _ = m.summarizeBatch(ctx, agent, validMessages, summary, instructions)
} }
if omitted && finalSummary != "" { if omitted && finalSummary != "" {
finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]" finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]"
} }
if finalSummary != "" { if finalSummary == "" {
agent.Sessions.SetSummary(sessionKey, finalSummary) return SessionSummarizePayload{}, false, nil
agent.Sessions.TruncateHistory(sessionKey, keepCount)
agent.Sessions.Save(sessionKey)
m.al.emitEvent(
EventKindSessionSummarize,
m.al.newTurnEventScope(agent.ID, sessionKey).meta(0, "summarizeSession", "turn.session.summarize"),
SessionSummarizePayload{
SummarizedMessages: len(validMessages),
KeptMessages: keepCount,
SummaryLen: len(finalSummary),
OmittedOversized: omitted,
},
)
} }
agent.Sessions.SetSummary(sessionKey, finalSummary)
agent.Sessions.TruncateHistory(sessionKey, keepCount)
agent.Sessions.Save(sessionKey)
m.al.incrementCompactionCount(sessionKey)
payload := SessionSummarizePayload{
SummarizedMessages: len(validMessages),
KeptMessages: keepCount,
SummaryLen: len(finalSummary),
OmittedOversized: omitted,
}
m.al.emitEvent(
EventKindSessionSummarize,
m.al.newTurnEventScope(agent.ID, sessionKey).meta(0, "summarizeSession", "turn.session.summarize"),
payload,
)
return payload, true, nil
} }
func (m *legacyContextManager) findNearestUserMessage(messages []providers.Message, mid int) int { func (m *legacyContextManager) findNearestUserMessage(messages []providers.Message, mid int) int {
@ -315,6 +350,7 @@ func (m *legacyContextManager) summarizeBatch(
agent *AgentInstance, agent *AgentInstance,
batch []providers.Message, batch []providers.Message,
existingSummary string, existingSummary string,
customInstructions string,
) (string, error) { ) (string, error) {
const ( const (
llmMaxRetries = 3 llmMaxRetries = 3
@ -329,6 +365,11 @@ func (m *legacyContextManager) summarizeBatch(
sb.WriteString(existingSummary) sb.WriteString(existingSummary)
sb.WriteString("\n") sb.WriteString("\n")
} }
if customInstructions != "" {
sb.WriteString("Additional focus for this manual compaction: ")
sb.WriteString(customInstructions)
sb.WriteString("\n")
}
sb.WriteString("\nCONVERSATION:\n") sb.WriteString("\nCONVERSATION:\n")
for _, msg := range batch { for _, msg := range batch {
fmt.Fprintf(&sb, "%s: %s\n", msg.Role, msg.Content) fmt.Fprintf(&sb, "%s: %s\n", msg.Role, msg.Content)

View file

@ -41,9 +41,11 @@ type AssembleResponse struct {
// CompactRequest is the input to Compact. // CompactRequest is the input to Compact.
type CompactRequest struct { type CompactRequest struct {
SessionKey string // session identifier SessionKey string // session identifier
Reason ContextCompressReason // proactive_budget | llm_retry | summarize Reason ContextCompressReason // proactive_budget | llm_retry | summarize
Budget int // context window budget (used for retry aggressive compaction) Budget int // context window budget (used for retry aggressive compaction)
Instructions string // optional extra guidance for manual compaction
Manual bool // true when triggered explicitly by the user
} }
// IngestRequest is the input to Ingest. // IngestRequest is the input to Ingest.

View file

@ -136,7 +136,7 @@ func (m *seahorseContextManager) Compact(ctx context.Context, req *CompactReques
} }
_, err := m.engine.Compact(ctx, req.SessionKey, seahorse.CompactInput{ _, err := m.engine.Compact(ctx, req.SessionKey, seahorse.CompactInput{
Force: req.Reason == ContextCompressReasonRetry, Force: req.Reason == ContextCompressReasonRetry || req.Manual,
Budget: &req.Budget, Budget: &req.Budget,
}) })
return err return err

View file

@ -69,6 +69,28 @@ type AgentLoop struct {
activeRequests sync.WaitGroup activeRequests sync.WaitGroup
reloadFunc func() error reloadFunc func() error
// Compression tracking (custom feature)
compressionCounters sync.Map // key: sessionKey, value: int64
}
func (al *AgentLoop) incrementCompactionCount(sessionKey string) {
if sessionKey == "" {
return
}
current, _ := al.compressionCounters.LoadOrStore(sessionKey, int64(0))
al.compressionCounters.Store(sessionKey, current.(int64)+1)
}
func (al *AgentLoop) getCompactionCount(sessionKey string) int {
if sessionKey == "" {
return 0
}
value, ok := al.compressionCounters.Load(sessionKey)
if !ok {
return 0
}
return int(value.(int64))
} }
// processOptions configures how a message is processed // processOptions configures how a message is processed
@ -3579,6 +3601,99 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
agent.Sessions.Save(opts.SessionKey) agent.Sessions.Save(opts.SessionKey)
return nil return nil
} }
rt.GetSessionStats = func() commands.SessionStats {
stats := commands.SessionStats{}
if opts == nil || agent.Sessions == nil {
return stats
}
stats.Version = config.GetVersion()
stats.SessionKey = opts.SessionKey
history := agent.Sessions.GetHistory(opts.SessionKey)
summary := agent.Sessions.GetSummary(opts.SessionKey)
stats.MessageCount = len(history)
tokenEstimate := 0
for _, msg := range history {
tokenEstimate += EstimateMessageTokens(msg)
}
if summary != "" {
tokenEstimate += EstimateMessageTokens(providers.Message{
Role: "system",
Content: summary,
})
stats.HasSummary = true
}
stats.TokenEstimate = tokenEstimate
stats.ContextWindow = agent.ContextWindow
if agent.ContextWindow > 0 {
stats.ContextPercent = float64(tokenEstimate) / float64(agent.ContextWindow) * 100
}
stats.ThinkEnabled = agent.ThinkingLevel != ThinkingOff
return stats
}
rt.CompactContext = func(instructions string) (int, error) {
if opts == nil {
return 0, fmt.Errorf("process options not available")
}
if al.contextManager == nil {
return 0, fmt.Errorf("context manager is not initialized")
}
if agent.Sessions == nil {
return 0, fmt.Errorf("sessions not initialized for agent")
}
beforeHistory := agent.Sessions.GetHistory(opts.SessionKey)
compactCtx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
if err := al.contextManager.Compact(compactCtx, &CompactRequest{
SessionKey: opts.SessionKey,
Reason: ContextCompressReasonSummarize,
Budget: agent.ContextWindow,
Instructions: instructions,
Manual: true,
}); err != nil {
return 0, err
}
afterHistory := agent.Sessions.GetHistory(opts.SessionKey)
if len(afterHistory) < len(beforeHistory) {
al.incrementCompactionCount(opts.SessionKey)
}
return len(beforeHistory) - len(afterHistory), nil
}
rt.NewSession = func() error {
if opts == nil {
return fmt.Errorf("process options not available")
}
if agent.Sessions == nil {
return fmt.Errorf("sessions not initialized for agent")
}
al.compressionCounters.Store(opts.SessionKey, int64(0))
agent.Sessions.SetHistory(opts.SessionKey, make([]providers.Message, 0))
agent.Sessions.SetSummary(opts.SessionKey, "")
agent.Sessions.Save(opts.SessionKey)
return nil
}
rt.GetCompactionCount = func() int {
if opts == nil {
return 0
}
return al.getCompactionCount(opts.SessionKey)
}
} }
return rt return rt
} }

View file

@ -0,0 +1,82 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/providers"
)
type promptCapturingProvider struct {
response string
prompts []string
}
func (p *promptCapturingProvider) Chat(
_ context.Context,
messages []providers.Message,
_ []providers.ToolDefinition,
_ string,
_ map[string]any,
) (*providers.LLMResponse, error) {
if len(messages) > 0 {
p.prompts = append(p.prompts, messages[0].Content)
}
return &providers.LLMResponse{
Content: p.response,
ToolCalls: []providers.ToolCall{},
}, nil
}
func (p *promptCapturingProvider) GetDefaultModel() string {
return "prompt-capturing-model"
}
func TestLegacyCompact_Manual_UsesInstructionsAndCompactsSynchronously(t *testing.T) {
cfg := testConfig(t)
provider := &promptCapturingProvider{response: "manual compact summary"}
al := NewAgentLoop(cfg, bus.NewMessageBus(), provider)
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("expected default agent")
}
history := []providers.Message{
{Role: "user", Content: "question one"},
{Role: "assistant", Content: "answer one"},
{Role: "user", Content: "question two"},
{Role: "assistant", Content: "answer two"},
{Role: "user", Content: "question three"},
{Role: "assistant", Content: "answer three"},
}
defaultAgent.Sessions.SetHistory("session-manual", history)
err := al.contextManager.Compact(context.Background(), &CompactRequest{
SessionKey: "session-manual",
Reason: ContextCompressReasonSummarize,
Instructions: "focus on decisions",
Manual: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
newHistory := defaultAgent.Sessions.GetHistory("session-manual")
if len(newHistory) >= len(history) {
t.Fatalf("expected compacted history, got %d messages (was %d)", len(newHistory), len(history))
}
summary := defaultAgent.Sessions.GetSummary("session-manual")
if !strings.Contains(summary, "manual compact summary") {
t.Fatalf("summary=%q, want manual summary content", summary)
}
if len(provider.prompts) == 0 || !strings.Contains(provider.prompts[0], "focus on decisions") {
t.Fatalf("expected compaction prompt to include manual instructions, got %v", provider.prompts)
}
if got := al.getCompactionCount("session-manual"); got != 1 {
t.Fatalf("compaction count=%d, want 1", got)
}
}

View file

@ -16,5 +16,8 @@ func BuiltinDefinitions() []Definition {
clearCommand(), clearCommand(),
subagentsCommand(), subagentsCommand(),
reloadCommand(), reloadCommand(),
statusCommand(),
compactCommand(),
newCommand(),
} }
} }

View file

@ -0,0 +1,52 @@
package commands
import (
"context"
"fmt"
"strings"
)
func compactCommand() Definition {
return Definition{
Name: "compact",
Description: "Compact session context and summarize older messages",
Usage: "/compact [instructions]",
Aliases: []string{"c"},
Handler: func(_ context.Context, req Request, rt *Runtime) error {
if rt == nil {
return req.Reply(unavailableMsg)
}
if rt.CompactContext == nil {
return req.Reply("Compaction is not available in the current context.")
}
var instructions string
if fields := strings.Fields(req.Text); len(fields) > 1 {
instructions = strings.Join(fields[1:], " ")
}
droppedMessages, err := rt.CompactContext(instructions)
if err != nil {
return req.Reply("Failed to compact context: " + err.Error())
}
if droppedMessages > 0 {
label := "(default)"
if instructions != "" {
label = instructions
}
return req.Reply(fmt.Sprintf(
"Context compacted.\n\n- Messages summarized: %d\n- Instructions: %s\n- Older context was condensed into the session summary.",
droppedMessages,
label,
))
}
if instructions != "" {
return req.Reply("Compaction requested, but the session was already small enough that no history was summarized.")
}
return req.Reply("Context is already compact enough. No summarization was needed.")
},
}
}

View file

@ -0,0 +1,81 @@
package commands
import (
"context"
"strings"
"testing"
)
func TestCompactCommand_PassesInstructionsToRuntime(t *testing.T) {
var gotInstructions string
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{
CompactContext: func(instructions string) (int, error) {
gotInstructions = instructions
return 3, nil
},
})
var reply string
res := ex.Execute(context.Background(), Request{
Text: "/compact focus on decisions",
Reply: func(text string) error {
reply = text
return nil
},
})
if res.Outcome != OutcomeHandled {
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
}
if gotInstructions != "focus on decisions" {
t.Fatalf("instructions=%q, want %q", gotInstructions, "focus on decisions")
}
if !strings.Contains(reply, "Messages summarized: 3") {
t.Fatalf("reply=%q, want summarized count", reply)
}
}
func TestStatusCommand_ShowsSummaryAndCompactions(t *testing.T) {
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{
GetModelInfo: func() (string, string) {
return "gpt-test", "openai"
},
GetSessionStats: func() SessionStats {
return SessionStats{
Version: "v1.2.3",
TokenEstimate: 1200,
ContextWindow: 8000,
ContextPercent: 15,
MessageCount: 6,
SessionKey: "agent:main:test",
ThinkEnabled: true,
HasSummary: true,
}
},
GetCompactionCount: func() int { return 2 },
})
var reply string
res := ex.Execute(context.Background(), Request{
Text: "/status",
Reply: func(text string) error {
reply = text
return nil
},
})
if res.Outcome != OutcomeHandled {
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
}
for _, want := range []string{
"PicoClaw v1.2.3",
"gpt-test",
"History:* 6 messages",
"Summary:* present",
"Compactions:* 2",
"Runtime:* direct",
"Think:* on",
} {
if !strings.Contains(reply, want) {
t.Fatalf("reply=%q, want substring %q", reply, want)
}
}
}

42
pkg/commands/cmd_new.go Normal file
View file

@ -0,0 +1,42 @@
package commands
import "context"
func newCommand() Definition {
return Definition{
Name: "new",
Description: "Start a new session (reset conversation history)",
Usage: "/new",
Aliases: []string{"reset"},
Handler: func(_ context.Context, req Request, rt *Runtime) error {
if rt == nil {
return req.Reply(unavailableMsg)
}
if rt.NewSession == nil {
if rt.ClearHistory != nil {
if err := rt.ClearHistory(); err != nil {
return req.Reply("Failed to start new session: " + err.Error())
}
return req.Reply(
"New session started.\n\n" +
"Previous conversation has been cleared.\n" +
"You now have a fresh context window.",
)
}
return req.Reply(unavailableMsg)
}
if err := rt.NewSession(); err != nil {
return req.Reply("Failed to start new session: " + err.Error())
}
return req.Reply(
"New session started.\n\n" +
"Previous conversation has been cleared.\n" +
"You now have a fresh context window.\n\n" +
"Tip: Use /compact to summarize older history before resetting a session.",
)
},
}
}

112
pkg/commands/cmd_status.go Normal file
View file

@ -0,0 +1,112 @@
package commands
import (
"context"
"fmt"
"strings"
)
func statusCommand() Definition {
return Definition{
Name: "status",
Description: "Show current session status (model, context, compactions)",
Usage: "/status",
Aliases: []string{"s"},
Handler: func(_ context.Context, req Request, rt *Runtime) error {
if rt == nil {
return req.Reply(unavailableMsg)
}
modelName, provider := "Unknown", "Unknown"
if rt.GetModelInfo != nil {
modelName, provider = rt.GetModelInfo()
}
stats := SessionStats{}
if rt.GetSessionStats != nil {
stats = rt.GetSessionStats()
}
compactions := 0
if rt.GetCompactionCount != nil {
compactions = rt.GetCompactionCount()
}
if stats.ContextWindow == 0 {
stats.ContextWindow = 200000
}
version := stats.Version
if version == "" {
version = "dev"
}
think := "off"
if stats.ThinkEnabled {
think = "on"
}
var sessionInfo strings.Builder
sessionInfo.WriteString(fmt.Sprintf("🦞 *PicoClaw %s*\n\n", version))
sessionInfo.WriteString(fmt.Sprintf("🧠 *Model:* %s/%s\n", provider, modelName))
sessionInfo.WriteString(fmt.Sprintf(
"📚 *Context:* %s %s/%s (%.1f%%)\n",
makeContextBar(stats.ContextPercent),
formatTokens(stats.TokenEstimate),
formatTokens(stats.ContextWindow),
stats.ContextPercent,
))
sessionInfo.WriteString(fmt.Sprintf("💬 *History:* %d messages\n", stats.MessageCount))
if stats.HasSummary {
sessionInfo.WriteString("📝 *Summary:* present\n")
} else {
sessionInfo.WriteString("📝 *Summary:* none\n")
}
sessionInfo.WriteString(fmt.Sprintf("🧹 *Compactions:* %d\n", compactions))
sessionInfo.WriteString("\n💡 *Tokens:* estimated from stored session history\n")
if stats.SessionKey != "" {
sessionInfo.WriteString(fmt.Sprintf("🧵 *Session:* %s", stats.SessionKey))
if stats.SessionUpdated != "" {
sessionInfo.WriteString(fmt.Sprintf(" - %s", stats.SessionUpdated))
}
sessionInfo.WriteString("\n")
}
sessionInfo.WriteString(fmt.Sprintf("⚙️ *Runtime:* direct · 🤖 *Think:* %s\n", think))
return req.Reply(sessionInfo.String())
},
}
}
func makeContextBar(percent float64) string {
const total = 20
filled := int(percent / 100 * float64(total))
if filled > total {
filled = total
}
if filled < 0 {
filled = 0
}
empty := total - filled
var bar strings.Builder
bar.WriteString("[")
for i := 0; i < filled; i++ {
bar.WriteString("█")
}
for i := 0; i < empty; i++ {
bar.WriteString("░")
}
bar.WriteString("]")
return bar.String()
}
func formatTokens(tokens int) string {
if tokens >= 1000000 {
return fmt.Sprintf("%.1fm", float64(tokens)/1000000)
} else if tokens >= 1000 {
return fmt.Sprintf("%.1fk", float64(tokens)/1000)
}
return fmt.Sprintf("%d", tokens)
}

View file

@ -17,4 +17,9 @@ type Runtime struct {
SwitchChannel func(value string) error SwitchChannel func(value string) error
ClearHistory func() error ClearHistory func() error
ReloadConfig func() error ReloadConfig func() error
GetSessionStats func() SessionStats
CompactContext func(instructions string) (droppedMessages int, err error)
NewSession func() error
GetCompactionCount func() int
} }

View file

@ -0,0 +1,13 @@
package commands
type SessionStats struct {
MessageCount int
TokenEstimate int
ContextPercent float64
ContextWindow int
SessionKey string
SessionUpdated string
Version string
ThinkEnabled bool
HasSummary bool
}