Add session management commands: /status, /compact, /new
This commit is contained in:
parent
6d03791929
commit
919933a45c
14 changed files with 966 additions and 27 deletions
241
CONTEXT_COMMANDS_RESEARCH.md
Normal file
241
CONTEXT_COMMANDS_RESEARCH.md
Normal 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
150
PICO_STARTUP_GUIDE.md
Normal 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,不要泄露
|
||||||
|
|
@ -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 == "" {
|
||||||
|
return SessionSummarizePayload{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
agent.Sessions.SetSummary(sessionKey, finalSummary)
|
agent.Sessions.SetSummary(sessionKey, finalSummary)
|
||||||
agent.Sessions.TruncateHistory(sessionKey, keepCount)
|
agent.Sessions.TruncateHistory(sessionKey, keepCount)
|
||||||
agent.Sessions.Save(sessionKey)
|
agent.Sessions.Save(sessionKey)
|
||||||
m.al.emitEvent(
|
m.al.incrementCompactionCount(sessionKey)
|
||||||
EventKindSessionSummarize,
|
|
||||||
m.al.newTurnEventScope(agent.ID, sessionKey).meta(0, "summarizeSession", "turn.session.summarize"),
|
payload := SessionSummarizePayload{
|
||||||
SessionSummarizePayload{
|
|
||||||
SummarizedMessages: len(validMessages),
|
SummarizedMessages: len(validMessages),
|
||||||
KeptMessages: keepCount,
|
KeptMessages: keepCount,
|
||||||
SummaryLen: len(finalSummary),
|
SummaryLen: len(finalSummary),
|
||||||
OmittedOversized: omitted,
|
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)
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
82
pkg/agent/manual_compact_test.go
Normal file
82
pkg/agent/manual_compact_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,5 +16,8 @@ func BuiltinDefinitions() []Definition {
|
||||||
clearCommand(),
|
clearCommand(),
|
||||||
subagentsCommand(),
|
subagentsCommand(),
|
||||||
reloadCommand(),
|
reloadCommand(),
|
||||||
|
statusCommand(),
|
||||||
|
compactCommand(),
|
||||||
|
newCommand(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
pkg/commands/cmd_compact.go
Normal file
52
pkg/commands/cmd_compact.go
Normal 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.")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
81
pkg/commands/cmd_context_test.go
Normal file
81
pkg/commands/cmd_context_test.go
Normal 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
42
pkg/commands/cmd_new.go
Normal 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
112
pkg/commands/cmd_status.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
pkg/commands/session_stats.go
Normal file
13
pkg/commands/session_stats.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue