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 {
if req == nil {
return nil
}
switch req.Reason {
case ContextCompressReasonProactive, ContextCompressReasonRetry:
// Sync emergency compression — budget exceeded.
if result, ok := m.forceCompression(req.SessionKey); ok {
m.al.incrementCompactionCount(req.SessionKey)
m.al.emitEvent(
EventKindContextCompress,
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:
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)
}
return nil
@ -87,7 +100,7 @@ func (m *legacyContextManager) maybeSummarize(sessionKey string) {
}
}()
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) {
_, _, _ = 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)
defer cancel()
history := agent.Sessions.GetHistory(sessionKey)
summary := agent.Sessions.GetSummary(sessionKey)
if len(history) <= 4 {
return
keepLast := 4
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 {
return
return SessionSummarizePayload{}, false, nil
}
keepCount := len(history) - safeCut
toSummarize := history[:safeCut]
@ -193,7 +219,7 @@ func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey
}
if len(validMessages) == 0 {
return
return SessionSummarizePayload{}, false, nil
}
const (
@ -209,13 +235,16 @@ func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey
part1 := validMessages[:mid]
part2 := validMessages[mid:]
s1, _ := m.summarizeBatch(ctx, agent, part1, "")
s2, _ := m.summarizeBatch(ctx, agent, part2, "")
s1, _ := m.summarizeBatch(ctx, agent, part1, "", instructions)
s2, _ := m.summarizeBatch(ctx, agent, part2, "", instructions)
mergePrompt := fmt.Sprintf(
"Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
s1, s2,
)
if instructions != "" {
mergePrompt += "\n\nAdditional focus for manual compaction: " + instructions
}
resp, err := m.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries)
if err == nil && resp.Content != "" {
@ -224,28 +253,34 @@ func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey
finalSummary = s1 + " " + s2
}
} else {
finalSummary, _ = m.summarizeBatch(ctx, agent, validMessages, summary)
finalSummary, _ = m.summarizeBatch(ctx, agent, validMessages, summary, instructions)
}
if omitted && finalSummary != "" {
finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]"
}
if finalSummary != "" {
agent.Sessions.SetSummary(sessionKey, finalSummary)
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,
},
)
if finalSummary == "" {
return SessionSummarizePayload{}, false, nil
}
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 {
@ -315,6 +350,7 @@ func (m *legacyContextManager) summarizeBatch(
agent *AgentInstance,
batch []providers.Message,
existingSummary string,
customInstructions string,
) (string, error) {
const (
llmMaxRetries = 3
@ -329,6 +365,11 @@ func (m *legacyContextManager) summarizeBatch(
sb.WriteString(existingSummary)
sb.WriteString("\n")
}
if customInstructions != "" {
sb.WriteString("Additional focus for this manual compaction: ")
sb.WriteString(customInstructions)
sb.WriteString("\n")
}
sb.WriteString("\nCONVERSATION:\n")
for _, msg := range batch {
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.
type CompactRequest struct {
SessionKey string // session identifier
Reason ContextCompressReason // proactive_budget | llm_retry | summarize
Budget int // context window budget (used for retry aggressive compaction)
SessionKey string // session identifier
Reason ContextCompressReason // proactive_budget | llm_retry | summarize
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.

View file

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

View file

@ -69,6 +69,28 @@ type AgentLoop struct {
activeRequests sync.WaitGroup
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
@ -3579,6 +3601,99 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
agent.Sessions.Save(opts.SessionKey)
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
}

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(),
subagentsCommand(),
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
ClearHistory 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
}