feat: add /plan command with phase-based task planning via MEMORY.md

MEMORY.md was injected into system prompts every turn but had no
structured use. This turns it into a plan store with interview flow,
phased execution, and selective context injection to prevent context
window bloat.

- /plan <task>: seeds MEMORY.md with interviewing status
- /plan clear|done|add|start|next: manage plan lifecycle
- Auto-advance phases when all steps marked [x]
- Auto-clear plan on full completion
- Interviewing: injects seed + interview guide + target format
- Executing: injects current phase only, compresses completed phases
- Commands section for build/test/lint CLI commands
- Daily notes suppressed during active plan to save context
- 22 new memory_test.go tests, 12 new loop_test.go tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-21 00:47:29 +09:00
parent ab0e1e6882
commit 3dfff32b27
12 changed files with 1613 additions and 10 deletions

View file

@ -820,10 +820,15 @@ Ces commandes slash peuvent être envoyées dans n'importe quel canal de chat (T
| `/skill <nom> [message]` | Invoquer une compétence | | `/skill <nom> [message]` | Invoquer une compétence |
| `/show model` | Afficher le modèle actuel | | `/show model` | Afficher le modèle actuel |
| `/list channels` | Lister les canaux activés | | `/list channels` | Lister les canaux activés |
| `/plan` | Afficher la progression du plan actuel |
| `/plan <tâche>` | Démarrer un nouveau plan avec entretien |
| `/plan clear` | Abandonner le plan actuel |
> **Note** : `/session` nécessite que la passerelle soit démarrée avec le flag `--stats`. Sans celui-ci, le suivi des statistiques est désactivé pour zéro surcharge. > **Note** : `/session` nécessite que la passerelle soit démarrée avec le flag `--stats`. Sans celui-ci, le suivi des statistiques est désactivé pour zéro surcharge.
> >
> **Note** : `/skills` répond instantanément sans coût LLM. `/skill <nom>` injecte le SKILL.md dans le contexte pour le tour en cours uniquement, puis seul le nom de la compétence est conservé dans l'historique pour économiser la fenêtre de contexte. > **Note** : `/skills` répond instantanément sans coût LLM. `/skill <nom>` injecte le SKILL.md dans le contexte pour le tour en cours uniquement, puis seul le nom de la compétence est conservé dans l'historique pour économiser la fenêtre de contexte.
>
> **Note** : `/plan` permet la planification structurée des tâches avec avancement automatique des phases. L'IA vous interview pour comprendre les exigences, organise le travail en phases et suit la progression via MEMORY.md.
### Tâches Planifiées / Rappels ### Tâches Planifiées / Rappels

View file

@ -768,10 +768,15 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
| `/skill <名前> [メッセージ]` | スキルを呼び出す | | `/skill <名前> [メッセージ]` | スキルを呼び出す |
| `/show model` | 現在のモデルを表示 | | `/show model` | 現在のモデルを表示 |
| `/list channels` | 有効なチャネルを一覧表示 | | `/list channels` | 有効なチャネルを一覧表示 |
| `/plan` | 現在の計画の進捗を表示 |
| `/plan <タスク>` | インタビュー形式で新しい計画を開始 |
| `/plan clear` | 現在の計画を破棄 |
> **注意**: `/session` はゲートウェイを `--stats` フラグ付きで起動する必要があります。フラグなしではオーバーヘッドゼロで統計追跡は無効です。 > **注意**: `/session` はゲートウェイを `--stats` フラグ付きで起動する必要があります。フラグなしではオーバーヘッドゼロで統計追跡は無効です。
> >
> **注意**: `/skills` は LLM コストゼロで即座に応答します。`/skill <名前>` はそのターンのみ SKILL.md をコンテキストに注入し、ターン完了後はスキル名のみ履歴に残してコンテキストウィンドウを節約します。 > **注意**: `/skills` は LLM コストゼロで即座に応答します。`/skill <名前>` はそのターンのみ SKILL.md をコンテキストに注入し、ターン完了後はスキル名のみ履歴に残してコンテキストウィンドウを節約します。
>
> **注意**: `/plan` は自動フェーズ進行付きの構造化タスク計画を実現します。AIがインタビューで要件を確認し、フェーズに分割して作業を進め、MEMORY.mdで進捗を管理します。
## 🤝 コントリビュート&ロードマップ ## 🤝 コントリビュート&ロードマップ

View file

@ -854,10 +854,15 @@ These slash commands can be sent in any chat channel (Telegram, Discord, etc.) a
| `/skill <name> [message]` | Invoke a skill with optional message | | `/skill <name> [message]` | Invoke a skill with optional message |
| `/show model` | Show current model | | `/show model` | Show current model |
| `/list channels` | List enabled channels | | `/list channels` | List enabled channels |
| `/plan` | Show current plan progress |
| `/plan <task>` | Start a new plan with interview flow |
| `/plan clear` | Discard the current plan |
> **Note**: `/session` requires the gateway to be started with `--stats` flag. Without it, stats tracking is disabled for zero overhead. > **Note**: `/session` requires the gateway to be started with `--stats` flag. Without it, stats tracking is disabled for zero overhead.
> >
> **Note**: `/skills` returns instantly without LLM cost. `/skill <name>` injects the skill's SKILL.md into the conversation context for the current turn, then only the skill name is kept in history to save the context window. > **Note**: `/skills` returns instantly without LLM cost. `/skill <name>` injects the skill's SKILL.md into the conversation context for the current turn, then only the skill name is kept in history to save the context window.
>
> **Note**: `/plan` enables structured task planning with automatic phase advancement. The AI interviews you to understand requirements, organizes work into phases, and tracks progress through MEMORY.md.
### Scheduled Tasks / Reminders ### Scheduled Tasks / Reminders

View file

@ -821,10 +821,15 @@ Estes comandos de barra podem ser enviados em qualquer canal de chat (Telegram,
| `/skill <nome> [mensagem]` | Invocar uma habilidade | | `/skill <nome> [mensagem]` | Invocar uma habilidade |
| `/show model` | Mostrar modelo atual | | `/show model` | Mostrar modelo atual |
| `/list channels` | Listar canais habilitados | | `/list channels` | Listar canais habilitados |
| `/plan` | Mostrar progresso do plano atual |
| `/plan <tarefa>` | Iniciar um novo plano com fluxo de entrevista |
| `/plan clear` | Descartar o plano atual |
> **Nota**: `/session` requer que o gateway seja iniciado com a flag `--stats`. Sem ela, o rastreamento de estatísticas é desabilitado para zero overhead. > **Nota**: `/session` requer que o gateway seja iniciado com a flag `--stats`. Sem ela, o rastreamento de estatísticas é desabilitado para zero overhead.
> >
> **Nota**: `/skills` responde instantaneamente sem custo LLM. `/skill <nome>` injeta o SKILL.md no contexto apenas para o turno atual, depois apenas o nome da habilidade é mantido no histórico para economizar a janela de contexto. > **Nota**: `/skills` responde instantaneamente sem custo LLM. `/skill <nome>` injeta o SKILL.md no contexto apenas para o turno atual, depois apenas o nome da habilidade é mantido no histórico para economizar a janela de contexto.
>
> **Nota**: `/plan` permite planejamento estruturado de tarefas com avanço automático de fases. A IA entrevista você para entender os requisitos, organiza o trabalho em fases e acompanha o progresso via MEMORY.md.
### Tarefas Agendadas / Lembretes ### Tarefas Agendadas / Lembretes

View file

@ -798,10 +798,15 @@ Các lệnh gạch chéo sau có thể gửi trong bất kỳ kênh chat nào (T
| `/skill <tên> [tin nhắn]` | Gọi một kỹ năng | | `/skill <tên> [tin nhắn]` | Gọi một kỹ năng |
| `/show model` | Hiển thị mô hình hiện tại | | `/show model` | Hiển thị mô hình hiện tại |
| `/list channels` | Liệt kê các kênh đã bật | | `/list channels` | Liệt kê các kênh đã bật |
| `/plan` | Hiển thị tiến độ kế hoạch hiện tại |
| `/plan <nhiệm vụ>` | Bắt đầu kế hoạch mới với phỏng vấn |
| `/plan clear` | Hủy bỏ kế hoạch hiện tại |
> **Lưu ý**: `/session` yêu cầu gateway được khởi động với cờ `--stats`. Không có cờ này, theo dõi thống kê bị tắt để không có overhead. > **Lưu ý**: `/session` yêu cầu gateway được khởi động với cờ `--stats`. Không có cờ này, theo dõi thống kê bị tắt để không có overhead.
> >
> **Lưu ý**: `/skills` phản hồi ngay lập tức không tốn chi phí LLM. `/skill <tên>` chỉ đưa SKILL.md vào ngữ cảnh cho lượt hiện tại, sau đó chỉ giữ lại tên kỹ năng trong lịch sử để tiết kiệm cửa sổ ngữ cảnh. > **Lưu ý**: `/skills` phản hồi ngay lập tức không tốn chi phí LLM. `/skill <tên>` chỉ đưa SKILL.md vào ngữ cảnh cho lượt hiện tại, sau đó chỉ giữ lại tên kỹ năng trong lịch sử để tiết kiệm cửa sổ ngữ cảnh.
>
> **Lưu ý**: `/plan` cho phép lập kế hoạch nhiệm vụ có cấu trúc với tự động chuyển giai đoạn. AI phỏng vấn bạn để hiểu yêu cầu, tổ chức công việc thành các giai đoạn và theo dõi tiến độ qua MEMORY.md.
### Tác vụ định kỳ / Nhắc nhở ### Tác vụ định kỳ / Nhắc nhở

View file

@ -688,10 +688,15 @@ picoclaw agent -m "你好"
| `/skill <名称> [消息]` | 调用技能 | | `/skill <名称> [消息]` | 调用技能 |
| `/show model` | 显示当前模型 | | `/show model` | 显示当前模型 |
| `/list channels` | 列出已启用的频道 | | `/list channels` | 列出已启用的频道 |
| `/plan` | 显示当前计划进度 |
| `/plan <任务>` | 以访谈方式启动新计划 |
| `/plan clear` | 丢弃当前计划 |
> **注意**: `/session` 需要使用 `--stats` 标志启动网关。未启用时统计跟踪被禁用,零开销。 > **注意**: `/session` 需要使用 `--stats` 标志启动网关。未启用时统计跟踪被禁用,零开销。
> >
> **注意**: `/skills` 即时响应,不消耗 LLM Token。`/skill <名称>` 仅在当前轮次将 SKILL.md 注入上下文,轮次结束后仅保留技能名称以节省上下文窗口。 > **注意**: `/skills` 即时响应,不消耗 LLM Token。`/skill <名称>` 仅在当前轮次将 SKILL.md 注入上下文,轮次结束后仅保留技能名称以节省上下文窗口。
>
> **注意**: `/plan` 实现带有自动阶段推进的结构化任务规划。AI通过访谈了解需求将工作组织成多个阶段并通过 MEMORY.md 跟踪进度。
### 定时任务 / 提醒 (Scheduled Tasks) ### 定时任务 / 提醒 (Scheduled Tasks)

View file

@ -80,8 +80,31 @@ Your workspace is at: %s
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. 2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
3. **Memory** - When remembering something, write to %s/memory/MEMORY.md`, 3. **Memory & Plans**
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath) - Use memory/MEMORY.md for structured plans.
- If Status is "interviewing": Ask clarifying questions.
Update Context with answers via edit_file.
When ready, organize into Phases and set Status to "executing".
- If Status is "executing": Work through the current Phase's steps.
Mark each [x] via edit_file. The system will auto-advance phases.
- Plan format:
# Active Plan
> Task: <description>
> Status: interviewing | executing
> Phase: <current phase number>
## Phase 1: <title>
- [ ] Step 1
## Phase 2: <title>
- [ ] Step 2
## Commands
build: <build command>
test: <test command>
lint: <lint command>
## Context
<requirements, decisions, environment>
- Keep each phase to 3-5 steps. Do NOT create plans without /plan.
- Always ask about build/test/lint commands during interview.`,
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection)
} }
func (cb *ContextBuilder) buildToolsSection() string { func (cb *ContextBuilder) buildToolsSection() string {
@ -263,6 +286,73 @@ func (cb *ContextBuilder) ListSkills() []skills.SkillInfo {
return cb.skillsLoader.ListSkills() return cb.skillsLoader.ListSkills()
} }
// ---------- Plan passthrough methods ----------
// ReadMemory reads the long-term memory (MEMORY.md).
func (cb *ContextBuilder) ReadMemory() string {
return cb.memory.ReadLongTerm()
}
// WriteMemory writes content to the long-term memory file.
func (cb *ContextBuilder) WriteMemory(content string) error {
return cb.memory.WriteLongTerm(content)
}
// ClearMemory removes the long-term memory file.
func (cb *ContextBuilder) ClearMemory() error {
return cb.memory.ClearLongTerm()
}
// HasActivePlan returns true if MEMORY.md contains an active plan.
func (cb *ContextBuilder) HasActivePlan() bool {
return cb.memory.HasActivePlan()
}
// GetPlanStatus returns the plan status: "interviewing", "executing", or "".
func (cb *ContextBuilder) GetPlanStatus() string {
return cb.memory.GetPlanStatus()
}
// IsPlanComplete returns true if all steps in all phases are [x].
func (cb *ContextBuilder) IsPlanComplete() bool {
return cb.memory.IsPlanComplete()
}
// IsCurrentPhaseComplete returns true if all steps in the current phase are [x].
func (cb *ContextBuilder) IsCurrentPhaseComplete() bool {
return cb.memory.IsCurrentPhaseComplete()
}
// AdvancePhase increments the current phase number by 1.
func (cb *ContextBuilder) AdvancePhase() error {
return cb.memory.AdvancePhase()
}
// GetCurrentPhase returns the current phase number.
func (cb *ContextBuilder) GetCurrentPhase() int {
return cb.memory.GetCurrentPhase()
}
// FormatPlanDisplay returns a user-facing display of the full plan.
func (cb *ContextBuilder) FormatPlanDisplay() string {
return cb.memory.FormatPlanDisplay()
}
// MarkStep marks a step as done in the specified phase.
func (cb *ContextBuilder) MarkStep(phase, step int) error {
return cb.memory.MarkStep(phase, step)
}
// AddStep appends a new step to the given phase.
func (cb *ContextBuilder) AddStep(phase int, desc string) error {
return cb.memory.AddStep(phase, desc)
}
// SetPlanStatus sets the plan status.
func (cb *ContextBuilder) SetPlanStatus(status string) error {
return cb.memory.SetStatus(status)
}
// GetSkillsInfo returns information about loaded skills. // GetSkillsInfo returns information about loaded skills.
func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} { func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
allSkills := cb.skillsLoader.ListSkills() allSkills := cb.skillsLoader.ListSkills()

View file

@ -10,6 +10,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@ -506,7 +507,34 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
// If last tool had ForUser content and we already sent it, we might not need to send final response // If last tool had ForUser content and we already sent it, we might not need to send final response
// This is controlled by the tool's Silent flag and ForUser content // This is controlled by the tool's Silent flag and ForUser content
// 5. Handle empty response // 5a. Auto-advance plan phases after LLM iteration
if agent.ContextBuilder.HasActivePlan() && agent.ContextBuilder.GetPlanStatus() == "executing" {
if agent.ContextBuilder.IsPlanComplete() {
_ = agent.ContextBuilder.ClearMemory()
if !constants.IsInternalChannel(opts.Channel) {
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: "\u2705 Plan completed!",
SkipPlaceholder: true,
})
}
} else if agent.ContextBuilder.IsCurrentPhaseComplete() {
prev := agent.ContextBuilder.GetCurrentPhase()
_ = agent.ContextBuilder.AdvancePhase()
next := agent.ContextBuilder.GetCurrentPhase()
if !constants.IsInternalChannel(opts.Channel) {
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: fmt.Sprintf("Phase %d complete. Moving to Phase %d.", prev, next),
SkipPlaceholder: true,
})
}
}
}
// 5b. Handle empty response
if finalContent == "" { if finalContent == "" {
finalContent = opts.DefaultResponse finalContent = opts.DefaultResponse
} }
@ -1131,6 +1159,9 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
// summarizeBatch summarizes a batch of messages. // summarizeBatch summarizes a batch of messages.
func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) { func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) {
prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n" prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n"
if agent.ContextBuilder.HasActivePlan() {
prompt += "Note: Active plan in MEMORY.md. Preserve plan progress references.\n"
}
if existingSummary != "" { if existingSummary != "" {
prompt += "Existing context: " + existingSummary + "\n" prompt += "Existing context: " + existingSummary + "\n"
} }
@ -1252,6 +1283,9 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
case "/skills": case "/skills":
return al.handleSkillsCommand(), true return al.handleSkillsCommand(), true
case "/plan":
return al.handlePlanCommand(args), true
} }
return "", false return "", false
@ -1363,6 +1397,96 @@ func (al *AgentLoop) handleSkillsCommand() string {
return sb.String() return sb.String()
} }
// handlePlanCommand handles /plan subcommands.
func (al *AgentLoop) handlePlanCommand(args []string) string {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "No agent configured."
}
if len(args) == 0 {
// /plan — show current plan
return agent.ContextBuilder.FormatPlanDisplay()
}
sub := args[0]
switch sub {
case "clear":
if !agent.ContextBuilder.HasActivePlan() {
return "No active plan to clear."
}
if err := agent.ContextBuilder.ClearMemory(); err != nil {
return fmt.Sprintf("Error clearing plan: %v", err)
}
return "Plan cleared."
case "done":
if !agent.ContextBuilder.HasActivePlan() {
return "No active plan."
}
if len(args) < 2 {
return "Usage: /plan done <step number>"
}
stepNum, err := strconv.Atoi(args[1])
if err != nil || stepNum < 1 {
return "Step number must be a positive integer."
}
phase := agent.ContextBuilder.GetCurrentPhase()
if err := agent.ContextBuilder.MarkStep(phase, stepNum); err != nil {
return fmt.Sprintf("Error: %v", err)
}
return fmt.Sprintf("Marked step %d in phase %d as done.", stepNum, phase)
case "add":
if !agent.ContextBuilder.HasActivePlan() {
return "No active plan."
}
if len(args) < 2 {
return "Usage: /plan add <step description>"
}
desc := strings.Join(args[1:], " ")
phase := agent.ContextBuilder.GetCurrentPhase()
if err := agent.ContextBuilder.AddStep(phase, desc); err != nil {
return fmt.Sprintf("Error: %v", err)
}
return fmt.Sprintf("Added step to phase %d: %s", phase, desc)
case "start":
if !agent.ContextBuilder.HasActivePlan() {
return "No active plan."
}
if agent.ContextBuilder.GetPlanStatus() != "interviewing" {
return "Plan is already executing."
}
if err := agent.ContextBuilder.SetPlanStatus("executing"); err != nil {
return fmt.Sprintf("Error: %v", err)
}
return "Plan status changed to executing."
case "next":
if !agent.ContextBuilder.HasActivePlan() {
return "No active plan."
}
if err := agent.ContextBuilder.AdvancePhase(); err != nil {
return fmt.Sprintf("Error: %v", err)
}
phase := agent.ContextBuilder.GetCurrentPhase()
return fmt.Sprintf("Advanced to phase %d.", phase)
default:
// /plan <task description> — start new plan
if agent.ContextBuilder.HasActivePlan() {
return "A plan is already active. Use /plan clear first."
}
task := strings.Join(args, " ")
seed := BuildInterviewSeed(task)
if err := agent.ContextBuilder.WriteMemory(seed); err != nil {
return fmt.Sprintf("Error creating plan: %v", err)
}
return fmt.Sprintf("Plan started: %s\nStatus: interviewing\n\nI'll ask you some questions to build a detailed plan.", task)
}
}
// extractPeer extracts the routing peer from inbound message metadata. // extractPeer extracts the routing peer from inbound message metadata.
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer { func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
peerKind := msg.Metadata["peer_kind"] peerKind := msg.Metadata["peer_kind"]

View file

@ -875,3 +875,390 @@ func TestBuildTaskReminder_Truncation(t *testing.T) {
t.Errorf("expected at most %d blocker chars, got %d", blockerMaxChars, xCount) t.Errorf("expected at most %d blocker chars, got %d", blockerMaxChars, xCount)
} }
} }
// ---------- /plan command tests ----------
func newTestAgentLoop(t *testing.T) (*AgentLoop, func()) {
t.Helper()
tmpDir, err := os.MkdirTemp("", "agent-plan-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &mockProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
return al, func() { os.RemoveAll(tmpDir) }
}
func TestPlanCommand_ShowNoPlan(t *testing.T) {
al, cleanup := newTestAgentLoop(t)
defer cleanup()
response, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan"})
if !handled {
t.Fatal("expected /plan to be handled")
}
if !strings.Contains(response, "No active plan") {
t.Errorf("expected 'No active plan', got %q", response)
}
}
func TestPlanCommand_StartNewPlan(t *testing.T) {
al, cleanup := newTestAgentLoop(t)
defer cleanup()
response, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Set up monitoring"})
if !handled {
t.Fatal("expected /plan to be handled")
}
if !strings.Contains(response, "Plan started") {
t.Errorf("expected 'Plan started', got %q", response)
}
if !strings.Contains(response, "Set up monitoring") {
t.Errorf("expected task in response, got %q", response)
}
// Verify plan was created
agent := al.registry.GetDefaultAgent()
if !agent.ContextBuilder.HasActivePlan() {
t.Error("expected active plan after /plan start")
}
if status := agent.ContextBuilder.GetPlanStatus(); status != "interviewing" {
t.Errorf("expected 'interviewing', got %q", status)
}
}
func TestPlanCommand_StartBlockedByExisting(t *testing.T) {
al, cleanup := newTestAgentLoop(t)
defer cleanup()
// Start first plan
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan First task"})
// Try to start another
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Second task"})
if !strings.Contains(response, "already active") {
t.Errorf("expected 'already active', got %q", response)
}
}
func TestPlanCommand_Clear(t *testing.T) {
al, cleanup := newTestAgentLoop(t)
defer cleanup()
// Start plan then clear
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Test task"})
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan clear"})
if !strings.Contains(response, "Plan cleared") {
t.Errorf("expected 'Plan cleared', got %q", response)
}
agent := al.registry.GetDefaultAgent()
if agent.ContextBuilder.HasActivePlan() {
t.Error("expected no plan after clear")
}
}
func TestPlanCommand_ClearNoPlan(t *testing.T) {
al, cleanup := newTestAgentLoop(t)
defer cleanup()
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan clear"})
if !strings.Contains(response, "No active plan") {
t.Errorf("expected 'No active plan', got %q", response)
}
}
func TestPlanCommand_Start(t *testing.T) {
al, cleanup := newTestAgentLoop(t)
defer cleanup()
// Create interviewing plan
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Test task"})
// Transition to executing
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"})
if !strings.Contains(response, "executing") {
t.Errorf("expected 'executing', got %q", response)
}
agent := al.registry.GetDefaultAgent()
if status := agent.ContextBuilder.GetPlanStatus(); status != "executing" {
t.Errorf("expected 'executing', got %q", status)
}
}
func TestPlanCommand_StartAlreadyExecuting(t *testing.T) {
al, cleanup := newTestAgentLoop(t)
defer cleanup()
// Create interviewing plan then start
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Test task"})
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"})
// Try start again
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"})
if !strings.Contains(response, "already executing") {
t.Errorf("expected 'already executing', got %q", response)
}
}
func TestPlanCommand_Done(t *testing.T) {
al, cleanup := newTestAgentLoop(t)
defer cleanup()
agent := al.registry.GetDefaultAgent()
// Write a plan directly with phases
plan := `# Active Plan
> Task: Test task
> Status: executing
> Phase: 1
## Phase 1: Setup
- [ ] Step one
- [ ] Step two
## Context
Test context
`
agent.ContextBuilder.WriteMemory(plan)
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan done 1"})
if !strings.Contains(response, "Marked step 1") {
t.Errorf("expected confirmation, got %q", response)
}
}
func TestPlanCommand_DoneInvalidStep(t *testing.T) {
al, cleanup := newTestAgentLoop(t)
defer cleanup()
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Test task"})
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan done abc"})
if !strings.Contains(response, "positive integer") {
t.Errorf("expected step validation error, got %q", response)
}
}
func TestPlanCommand_Add(t *testing.T) {
al, cleanup := newTestAgentLoop(t)
defer cleanup()
agent := al.registry.GetDefaultAgent()
plan := `# Active Plan
> Task: Test task
> Status: executing
> Phase: 1
## Phase 1: Setup
- [ ] Step one
## Context
Test context
`
agent.ContextBuilder.WriteMemory(plan)
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan add New step here"})
if !strings.Contains(response, "Added step") {
t.Errorf("expected 'Added step', got %q", response)
}
content := agent.ContextBuilder.ReadMemory()
if !strings.Contains(content, "New step here") {
t.Error("expected new step in plan content")
}
}
func TestPlanCommand_Next(t *testing.T) {
al, cleanup := newTestAgentLoop(t)
defer cleanup()
agent := al.registry.GetDefaultAgent()
plan := `# Active Plan
> Task: Test task
> Status: executing
> Phase: 1
## Phase 1: Setup
- [x] Step one
## Phase 2: Deploy
- [ ] Step two
## Context
Test
`
agent.ContextBuilder.WriteMemory(plan)
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan next"})
if !strings.Contains(response, "phase 2") {
t.Errorf("expected 'phase 2', got %q", response)
}
if phase := agent.ContextBuilder.GetCurrentPhase(); phase != 2 {
t.Errorf("expected phase 2, got %d", phase)
}
}
func TestPlanCommand_ShowActivePlan(t *testing.T) {
al, cleanup := newTestAgentLoop(t)
defer cleanup()
agent := al.registry.GetDefaultAgent()
plan := `# Active Plan
> Task: Deploy app
> Status: executing
> Phase: 1
## Phase 1: Build
- [x] Compile code
- [ ] Run tests
## Context
Production server
`
agent.ContextBuilder.WriteMemory(plan)
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan"})
if !strings.Contains(response, "Deploy app") {
t.Errorf("expected task name in display, got %q", response)
}
if !strings.Contains(response, "Phase 1") {
t.Errorf("expected phase info in display, got %q", response)
}
}
// TestAutoPhaseAdvance verifies that auto-advance sends notification after LLM iteration
// when current phase is complete.
func TestAutoPhaseAdvance(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-auto-advance-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &simpleMockProvider{response: "OK"}
al := NewAgentLoop(cfg, msgBus, provider)
agent := al.registry.GetDefaultAgent()
if agent == nil {
t.Fatal("No default agent")
}
// Write plan with phase 1 complete
plan := `# Active Plan
> Task: Test auto advance
> Status: executing
> Phase: 1
## Phase 1: Setup
- [x] Step one
- [x] Step two
## Phase 2: Deploy
- [ ] Step three
## Context
Test
`
agent.ContextBuilder.WriteMemory(plan)
// Process a message which triggers runAgentLoop
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = al.ProcessDirectWithChannel(ctx, "continue", "auto-advance-test", "test", "chat1")
if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
}
// After processing, phase should be auto-advanced
if phase := agent.ContextBuilder.GetCurrentPhase(); phase != 2 {
t.Errorf("expected phase auto-advanced to 2, got %d", phase)
}
}
// TestAutoCompleteClears verifies that plan is cleared when all phases are complete.
func TestAutoCompleteClears(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-auto-complete-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &simpleMockProvider{response: "All done"}
al := NewAgentLoop(cfg, msgBus, provider)
agent := al.registry.GetDefaultAgent()
if agent == nil {
t.Fatal("No default agent")
}
// Write fully complete plan
plan := `# Active Plan
> Task: Test auto complete
> Status: executing
> Phase: 1
## Phase 1: Setup
- [x] Step one
- [x] Step two
## Context
Test
`
agent.ContextBuilder.WriteMemory(plan)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = al.ProcessDirectWithChannel(ctx, "finish up", "auto-complete-test", "test", "chat1")
if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
}
// Plan should be cleared
if agent.ContextBuilder.HasActivePlan() {
t.Error("expected plan to be cleared after completion")
}
}

View file

@ -10,6 +10,9 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strconv"
"strings"
"time" "time"
) )
@ -60,6 +63,14 @@ func (ms *MemoryStore) WriteLongTerm(content string) error {
return os.WriteFile(ms.memoryFile, []byte(content), 0644) return os.WriteFile(ms.memoryFile, []byte(content), 0644)
} }
// ClearLongTerm removes the long-term memory file.
func (ms *MemoryStore) ClearLongTerm() error {
if err := os.Remove(ms.memoryFile); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// ReadToday reads today's daily note. // ReadToday reads today's daily note.
// Returns empty string if the file doesn't exist. // Returns empty string if the file doesn't exist.
func (ms *MemoryStore) ReadToday() string { func (ms *MemoryStore) ReadToday() string {
@ -128,22 +139,456 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
return result return result
} }
// ---------- Plan state query methods ----------
var (
reActivePlan = regexp.MustCompile(`(?m)^# Active Plan`)
reStatus = regexp.MustCompile(`(?m)^> Status:\s*(.+)`)
rePhase = regexp.MustCompile(`(?m)^> Phase:\s*(\d+)`)
rePhaseHeader = regexp.MustCompile(`(?m)^## Phase (\d+):\s*(.*)`)
reStepDone = regexp.MustCompile(`(?m)^- \[x\] `)
reStepTodo = regexp.MustCompile(`(?m)^- \[ \] `)
)
// HasActivePlan returns true if MEMORY.md contains an active plan.
func (ms *MemoryStore) HasActivePlan() bool {
content := ms.ReadLongTerm()
return reActivePlan.MatchString(content)
}
// GetPlanStatus returns the plan status: "interviewing", "executing", or "".
func (ms *MemoryStore) GetPlanStatus() string {
content := ms.ReadLongTerm()
m := reStatus.FindStringSubmatch(content)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
}
// GetCurrentPhase returns the current phase number from "> Phase: N".
func (ms *MemoryStore) GetCurrentPhase() int {
content := ms.ReadLongTerm()
m := rePhase.FindStringSubmatch(content)
if len(m) < 2 {
return 0
}
n, _ := strconv.Atoi(m[1])
return n
}
// GetTotalPhases returns the total number of phases (max ## Phase N).
func (ms *MemoryStore) GetTotalPhases() int {
content := ms.ReadLongTerm()
matches := rePhaseHeader.FindAllStringSubmatch(content, -1)
max := 0
for _, m := range matches {
if len(m) >= 2 {
n, _ := strconv.Atoi(m[1])
if n > max {
max = n
}
}
}
return max
}
// IsPlanComplete returns true if all steps in all phases are [x].
func (ms *MemoryStore) IsPlanComplete() bool {
content := ms.ReadLongTerm()
if !reActivePlan.MatchString(content) {
return false
}
// Must have at least one step
if !reStepDone.MatchString(content) && !reStepTodo.MatchString(content) {
return false
}
// No unchecked steps
return !reStepTodo.MatchString(content)
}
// IsCurrentPhaseComplete returns true if all steps in the current phase are [x].
func (ms *MemoryStore) IsCurrentPhaseComplete() bool {
content := ms.ReadLongTerm()
phase := ms.GetCurrentPhase()
if phase == 0 {
return false
}
phaseContent := ms.extractPhaseContent(content, phase)
if phaseContent == "" {
return false
}
// Must have at least one step
if !reStepDone.MatchString(phaseContent) && !reStepTodo.MatchString(phaseContent) {
return false
}
return !reStepTodo.MatchString(phaseContent)
}
// extractPhaseContent returns the content of a specific phase section.
func (ms *MemoryStore) extractPhaseContent(content string, phase int) string {
lines := strings.Split(content, "\n")
inPhase := false
var result []string
phasePrefix := fmt.Sprintf("## Phase %d:", phase)
for _, line := range lines {
if strings.HasPrefix(line, phasePrefix) {
inPhase = true
continue
}
if inPhase {
// Stop at next phase header or Context section
if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") {
break
}
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
// ---------- Plan mutation methods ----------
// SetStatus sets the plan status (interviewing or executing).
func (ms *MemoryStore) SetStatus(status string) error {
content := ms.ReadLongTerm()
if m := reStatus.FindString(content); m != "" {
content = strings.Replace(content, m, "> Status: "+status, 1)
}
return ms.WriteLongTerm(content)
}
// AdvancePhase increments the current phase number by 1.
func (ms *MemoryStore) AdvancePhase() error {
content := ms.ReadLongTerm()
m := rePhase.FindStringSubmatch(content)
if len(m) < 2 {
return fmt.Errorf("no phase marker found")
}
current, _ := strconv.Atoi(m[1])
next := current + 1
content = strings.Replace(content, m[0], fmt.Sprintf("> Phase: %d", next), 1)
return ms.WriteLongTerm(content)
}
// MarkStep marks the nth step (1-based) in the given phase as done [x].
func (ms *MemoryStore) MarkStep(phase, step int) error {
content := ms.ReadLongTerm()
lines := strings.Split(content, "\n")
phasePrefix := fmt.Sprintf("## Phase %d:", phase)
inPhase := false
stepCount := 0
for i, line := range lines {
if strings.HasPrefix(line, phasePrefix) {
inPhase = true
continue
}
if inPhase {
if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") {
break
}
if strings.HasPrefix(line, "- [ ] ") {
stepCount++
if stepCount == step {
lines[i] = strings.Replace(line, "- [ ] ", "- [x] ", 1)
return ms.WriteLongTerm(strings.Join(lines, "\n"))
}
}
}
}
return fmt.Errorf("step %d not found in phase %d", step, phase)
}
// AddStep appends a new step to the given phase.
func (ms *MemoryStore) AddStep(phase int, desc string) error {
content := ms.ReadLongTerm()
lines := strings.Split(content, "\n")
phasePrefix := fmt.Sprintf("## Phase %d:", phase)
inPhase := false
insertIdx := -1
for i, line := range lines {
if strings.HasPrefix(line, phasePrefix) {
inPhase = true
continue
}
if inPhase {
if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") {
insertIdx = i
break
}
// Track last step line
if strings.HasPrefix(line, "- [") {
insertIdx = i + 1
}
}
}
if insertIdx < 0 {
// Phase not found or empty; append at end
if inPhase {
insertIdx = len(lines)
} else {
return fmt.Errorf("phase %d not found", phase)
}
}
newStep := "- [ ] " + desc
newLines := make([]string, 0, len(lines)+1)
newLines = append(newLines, lines[:insertIdx]...)
newLines = append(newLines, newStep)
newLines = append(newLines, lines[insertIdx:]...)
return ms.WriteLongTerm(strings.Join(newLines, "\n"))
}
// ---------- Selective injection methods ----------
// interviewSeed is the initial content written to MEMORY.md when /plan starts.
const interviewSeedTemplate = `# Active Plan
> Task: %s
> Status: interviewing
> Phase: 1
`
// BuildInterviewSeed creates the initial plan seed for a given task description.
func BuildInterviewSeed(task string) string {
return fmt.Sprintf(interviewSeedTemplate, task)
}
// GetInterviewContext returns context for injection during the interviewing phase.
// Includes the full seed + interview guide + target format template.
func (ms *MemoryStore) GetInterviewContext() string {
content := ms.ReadLongTerm()
var sb strings.Builder
sb.WriteString("## Active Plan (interviewing)\n\n")
sb.WriteString(content)
sb.WriteString("\n\n### Interview Guide\n")
sb.WriteString("Ask about:\n")
sb.WriteString("- Goals and success criteria\n")
sb.WriteString("- Constraints (time, budget, platform)\n")
sb.WriteString("- Environment (OS, language, runtime versions)\n")
sb.WriteString("- Tooling preferences (test framework, linter, formatter, CI)\n")
sb.WriteString("- Key commands the user already runs (build, test, deploy)\n")
sb.WriteString("When ready, organize into 2-5 phases with 3-5 steps each.\n")
sb.WriteString("\n### Target Format\n")
sb.WriteString("```\n")
sb.WriteString("## Phase 1: <title>\n")
sb.WriteString("- [ ] Step\n")
sb.WriteString("## Phase 2: <title>\n")
sb.WriteString("- [ ] Step\n")
sb.WriteString("## Commands\n")
sb.WriteString("build: go build ./...\n")
sb.WriteString("test: go test ./pkg/... -count=1\n")
sb.WriteString("lint: golangci-lint run\n")
sb.WriteString("## Context\n")
sb.WriteString("<collected requirements, decisions, environment>\n")
sb.WriteString("```\n")
return sb.String()
}
// GetPlanContext returns context for injection during the executing phase.
// Only the current phase is shown in detail; completed phases are compressed
// to one-line summaries; future phases are omitted.
func (ms *MemoryStore) GetPlanContext() string {
content := ms.ReadLongTerm()
currentPhase := ms.GetCurrentPhase()
totalPhases := ms.GetTotalPhases()
// Extract task description
taskLine := ""
if m := regexp.MustCompile(`(?m)^> Task:\s*(.+)`).FindStringSubmatch(content); len(m) >= 2 {
taskLine = strings.TrimSpace(m[1])
}
var sb strings.Builder
sb.WriteString("## Active Plan\n")
sb.WriteString(fmt.Sprintf("Task: %s | Phase %d/%d\n", taskLine, currentPhase, totalPhases))
// Completed phases: one-line summaries
for p := 1; p < currentPhase; p++ {
title := ms.getPhaseTitle(content, p)
sb.WriteString(fmt.Sprintf("Done: Phase %d (%s)\n", p, title))
}
// Current phase: full detail
if currentPhase > 0 {
title := ms.getPhaseTitle(content, currentPhase)
sb.WriteString(fmt.Sprintf("### Current: Phase %d — %s\n", currentPhase, title))
phaseContent := ms.extractPhaseContent(content, currentPhase)
sb.WriteString(strings.TrimSpace(phaseContent))
sb.WriteString("\n")
}
// Commands section: always included if present
commandsContent := ms.extractCommandsSection(content)
if commandsContent != "" {
sb.WriteString("### Commands\n")
sb.WriteString(commandsContent)
sb.WriteString("\n")
}
// Context section: always included
contextContent := ms.extractContextSection(content)
if contextContent != "" {
sb.WriteString("### Context\n")
sb.WriteString(contextContent)
sb.WriteString("\n")
}
return sb.String()
}
// getPhaseTitle extracts the title of a phase from "## Phase N: Title".
func (ms *MemoryStore) getPhaseTitle(content string, phase int) string {
matches := rePhaseHeader.FindAllStringSubmatch(content, -1)
for _, m := range matches {
if len(m) >= 3 {
n, _ := strconv.Atoi(m[1])
if n == phase {
return strings.TrimSpace(m[2])
}
}
}
return ""
}
// extractSection extracts a named ## section from the plan content.
// It returns everything between "## <name>" and the next "## " header.
func (ms *MemoryStore) extractSection(content, name string) string {
lines := strings.Split(content, "\n")
prefix := "## " + name
inSection := false
var result []string
for _, line := range lines {
if strings.HasPrefix(line, prefix) {
inSection = true
continue
}
if inSection {
if strings.HasPrefix(line, "## ") {
break
}
result = append(result, line)
}
}
return strings.TrimSpace(strings.Join(result, "\n"))
}
// extractContextSection extracts the ## Context section from the plan.
func (ms *MemoryStore) extractContextSection(content string) string {
return ms.extractSection(content, "Context")
}
// extractCommandsSection extracts the ## Commands section from the plan.
func (ms *MemoryStore) extractCommandsSection(content string) string {
return ms.extractSection(content, "Commands")
}
// FormatPlanDisplay returns a user-facing display of the full plan with emoji indicators.
func (ms *MemoryStore) FormatPlanDisplay() string {
content := ms.ReadLongTerm()
if !ms.HasActivePlan() {
return "No active plan."
}
taskLine := ""
if m := regexp.MustCompile(`(?m)^> Task:\s*(.+)`).FindStringSubmatch(content); len(m) >= 2 {
taskLine = strings.TrimSpace(m[1])
}
status := ms.GetPlanStatus()
currentPhase := ms.GetCurrentPhase()
totalPhases := ms.GetTotalPhases()
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Plan: %s\n", taskLine))
sb.WriteString(fmt.Sprintf("Status: %s | Phase %d/%d\n\n", status, currentPhase, totalPhases))
for p := 1; p <= totalPhases; p++ {
title := ms.getPhaseTitle(content, p)
phaseContent := ms.extractPhaseContent(content, p)
// Determine phase emoji
var emoji string
if p < currentPhase {
emoji = "\u2705" // checkmark
} else if p == currentPhase {
emoji = "\u25B6\uFE0F" // play button
} else {
emoji = "\u23F3" // hourglass
}
sb.WriteString(fmt.Sprintf("%s Phase %d: %s\n", emoji, p, title))
// Show steps for current and completed phases
if p <= currentPhase {
lines := strings.Split(phaseContent, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "- [x] ") {
sb.WriteString(" \u2611 " + line[6:] + "\n")
} else if strings.HasPrefix(line, "- [ ] ") {
sb.WriteString(" \u2610 " + line[6:] + "\n")
}
}
}
}
commandsContent := ms.extractCommandsSection(content)
if commandsContent != "" {
sb.WriteString("\nCommands:\n")
for _, line := range strings.Split(commandsContent, "\n") {
line = strings.TrimSpace(line)
if line != "" {
sb.WriteString(" " + line + "\n")
}
}
}
contextContent := ms.extractContextSection(content)
if contextContent != "" {
sb.WriteString("\nContext: " + contextContent + "\n")
}
return sb.String()
}
// ---------- GetMemoryContext (plan-aware) ----------
// GetMemoryContext returns formatted memory context for the agent prompt. // GetMemoryContext returns formatted memory context for the agent prompt.
// Includes long-term memory and recent daily notes. // When an active plan exists, it uses selective injection based on plan status.
// During interviewing: full seed + interview guide.
// During executing: current phase only with compressed completed phases.
// When plan is active, daily notes injection is suppressed to save context.
func (ms *MemoryStore) GetMemoryContext() string { func (ms *MemoryStore) GetMemoryContext() string {
var parts []string var parts []string
// Long-term memory
longTerm := ms.ReadLongTerm() longTerm := ms.ReadLongTerm()
if longTerm != "" { if longTerm != "" {
if ms.HasActivePlan() {
status := ms.GetPlanStatus()
if status == "interviewing" {
parts = append(parts, ms.GetInterviewContext())
} else {
parts = append(parts, ms.GetPlanContext())
}
} else {
parts = append(parts, "## Long-term Memory\n\n"+longTerm) parts = append(parts, "## Long-term Memory\n\n"+longTerm)
} }
}
// Recent daily notes (last 3 days) // Suppress daily notes when a plan is active to save context
if !ms.HasActivePlan() {
recentNotes := ms.GetRecentDailyNotes(3) recentNotes := ms.GetRecentDailyNotes(3)
if recentNotes != "" { if recentNotes != "" {
parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes) parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes)
} }
}
if len(parts) == 0 { if len(parts) == 0 {
return "" return ""

526
pkg/agent/memory_test.go Normal file
View file

@ -0,0 +1,526 @@
package agent
import (
"os"
"path/filepath"
"strings"
"testing"
)
func newTestMemoryStore(t *testing.T) (*MemoryStore, func()) {
t.Helper()
tmpDir, err := os.MkdirTemp("", "memory-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
ms := NewMemoryStore(tmpDir)
return ms, func() { os.RemoveAll(tmpDir) }
}
const testPlanInterviewing = `# Active Plan
> Task: Set up server monitoring
> Status: interviewing
> Phase: 1
`
const testPlanExecuting = `# Active Plan
> Task: Set up server monitoring
> Status: executing
> Phase: 2
## Phase 1: Prometheus Install
- [x] Install Prometheus
- [x] Configure node_exporter
## Phase 2: Grafana Setup
- [ ] Install Grafana
- [ ] Create dashboard
## Phase 3: Alert Configuration
- [ ] Set up alert rules
- [ ] Configure Telegram notifications
## Commands
build: go build ./...
test: go test ./pkg/... -count=1
lint: golangci-lint run
## Context
Pi: Debian Bookworm arm64, ports: 3000/9090
`
const testPlanPhase1Complete = `# Active Plan
> Task: Set up server monitoring
> Status: executing
> Phase: 1
## Phase 1: Prometheus Install
- [x] Install Prometheus
- [x] Configure node_exporter
## Phase 2: Grafana Setup
- [ ] Install Grafana
- [ ] Create dashboard
## Context
Pi: Debian Bookworm arm64
`
const testPlanAllComplete = `# Active Plan
> Task: Set up server monitoring
> Status: executing
> Phase: 2
## Phase 1: Prometheus Install
- [x] Install Prometheus
- [x] Configure node_exporter
## Phase 2: Grafana Setup
- [x] Install Grafana
- [x] Create dashboard
## Context
Pi: Debian Bookworm arm64
`
func TestHasActivePlan(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
// No plan
if ms.HasActivePlan() {
t.Error("expected no active plan for empty memory")
}
// With regular content
ms.WriteLongTerm("Some random notes")
if ms.HasActivePlan() {
t.Error("expected no active plan for regular content")
}
// With active plan
ms.WriteLongTerm(testPlanExecuting)
if !ms.HasActivePlan() {
t.Error("expected active plan to be detected")
}
}
func TestGetPlanStatus(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
// No plan
if status := ms.GetPlanStatus(); status != "" {
t.Errorf("expected empty status, got %q", status)
}
// Interviewing
ms.WriteLongTerm(testPlanInterviewing)
if status := ms.GetPlanStatus(); status != "interviewing" {
t.Errorf("expected 'interviewing', got %q", status)
}
// Executing
ms.WriteLongTerm(testPlanExecuting)
if status := ms.GetPlanStatus(); status != "executing" {
t.Errorf("expected 'executing', got %q", status)
}
}
func TestGetCurrentPhase(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
// No plan
if phase := ms.GetCurrentPhase(); phase != 0 {
t.Errorf("expected phase 0, got %d", phase)
}
// Phase 2
ms.WriteLongTerm(testPlanExecuting)
if phase := ms.GetCurrentPhase(); phase != 2 {
t.Errorf("expected phase 2, got %d", phase)
}
}
func TestGetTotalPhases(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
// No plan
if total := ms.GetTotalPhases(); total != 0 {
t.Errorf("expected 0 phases, got %d", total)
}
// 3 phases
ms.WriteLongTerm(testPlanExecuting)
if total := ms.GetTotalPhases(); total != 3 {
t.Errorf("expected 3 phases, got %d", total)
}
}
func TestIsPlanComplete(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
// Not complete
ms.WriteLongTerm(testPlanExecuting)
if ms.IsPlanComplete() {
t.Error("expected plan to be incomplete")
}
// All complete
ms.WriteLongTerm(testPlanAllComplete)
if !ms.IsPlanComplete() {
t.Error("expected plan to be complete")
}
// No plan
ms.ClearLongTerm()
if ms.IsPlanComplete() {
t.Error("expected false when no plan exists")
}
}
func TestIsCurrentPhaseComplete(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
// Phase 2 not complete
ms.WriteLongTerm(testPlanExecuting)
if ms.IsCurrentPhaseComplete() {
t.Error("expected current phase to be incomplete")
}
// Phase 1 complete (current=1)
ms.WriteLongTerm(testPlanPhase1Complete)
if !ms.IsCurrentPhaseComplete() {
t.Error("expected phase 1 to be complete")
}
}
func TestSetStatus(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
ms.WriteLongTerm(testPlanInterviewing)
if err := ms.SetStatus("executing"); err != nil {
t.Fatalf("SetStatus failed: %v", err)
}
if status := ms.GetPlanStatus(); status != "executing" {
t.Errorf("expected 'executing', got %q", status)
}
}
func TestAdvancePhase(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
ms.WriteLongTerm(testPlanPhase1Complete)
if err := ms.AdvancePhase(); err != nil {
t.Fatalf("AdvancePhase failed: %v", err)
}
if phase := ms.GetCurrentPhase(); phase != 2 {
t.Errorf("expected phase 2 after advance, got %d", phase)
}
}
func TestMarkStep(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
ms.WriteLongTerm(testPlanExecuting)
// Mark step 1 in phase 2
if err := ms.MarkStep(2, 1); err != nil {
t.Fatalf("MarkStep failed: %v", err)
}
content := ms.ReadLongTerm()
// Phase 2 should have first step checked
lines := strings.Split(content, "\n")
foundChecked := false
inPhase2 := false
for _, line := range lines {
if strings.HasPrefix(line, "## Phase 2:") {
inPhase2 = true
continue
}
if inPhase2 && strings.HasPrefix(line, "## ") {
break
}
if inPhase2 && strings.HasPrefix(line, "- [x] Install Grafana") {
foundChecked = true
}
}
if !foundChecked {
t.Error("expected 'Install Grafana' to be marked [x]")
}
// Error case: invalid step
if err := ms.MarkStep(2, 99); err == nil {
t.Error("expected error for invalid step number")
}
}
func TestAddStep(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
ms.WriteLongTerm(testPlanExecuting)
// Add step to phase 2
if err := ms.AddStep(2, "Test dashboard"); err != nil {
t.Fatalf("AddStep failed: %v", err)
}
content := ms.ReadLongTerm()
if !strings.Contains(content, "- [ ] Test dashboard") {
t.Error("expected new step to be added")
}
// Verify it's in the right place (before Phase 3)
idx := strings.Index(content, "- [ ] Test dashboard")
phase3Idx := strings.Index(content, "## Phase 3:")
if idx > phase3Idx {
t.Error("expected new step to be before Phase 3")
}
}
func TestClearLongTerm(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
ms.WriteLongTerm(testPlanExecuting)
if err := ms.ClearLongTerm(); err != nil {
t.Fatalf("ClearLongTerm failed: %v", err)
}
if content := ms.ReadLongTerm(); content != "" {
t.Errorf("expected empty memory after clear, got %q", content)
}
// Clearing again should not error
if err := ms.ClearLongTerm(); err != nil {
t.Fatalf("ClearLongTerm (idempotent) failed: %v", err)
}
}
func TestGetInterviewContext(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
ms.WriteLongTerm(testPlanInterviewing)
ctx := ms.GetInterviewContext()
if !strings.Contains(ctx, "Active Plan (interviewing)") {
t.Error("expected 'Active Plan (interviewing)' header")
}
if !strings.Contains(ctx, "Interview Guide") {
t.Error("expected 'Interview Guide' section")
}
if !strings.Contains(ctx, "Target Format") {
t.Error("expected 'Target Format' section")
}
if !strings.Contains(ctx, "Set up server monitoring") {
t.Error("expected task description in context")
}
// Should guide AI to ask about tooling
if !strings.Contains(ctx, "test framework") || !strings.Contains(ctx, "linter") {
t.Error("expected interview guide to mention test framework and linter")
}
// Target format should include Commands section example
if !strings.Contains(ctx, "## Commands") {
t.Error("expected target format to include ## Commands section")
}
if !strings.Contains(ctx, "go test") {
t.Error("expected target format Commands to include test command example")
}
if !strings.Contains(ctx, "golangci-lint") {
t.Error("expected target format Commands to include lint command example")
}
}
func TestGetPlanContext(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
ms.WriteLongTerm(testPlanExecuting)
ctx := ms.GetPlanContext()
// Should have task summary
if !strings.Contains(ctx, "Phase 2/3") {
t.Error("expected 'Phase 2/3' in plan context")
}
// Completed phase should be summarized
if !strings.Contains(ctx, "Done: Phase 1") {
t.Error("expected completed phase summary")
}
// Current phase should have full detail
if !strings.Contains(ctx, "Current: Phase 2") {
t.Error("expected current phase detail")
}
if !strings.Contains(ctx, "Install Grafana") {
t.Error("expected current phase steps")
}
// Future phases should NOT appear
if strings.Contains(ctx, "Phase 3") {
t.Error("expected future phases to be omitted")
}
// Commands should be included
if !strings.Contains(ctx, "### Commands") {
t.Error("expected Commands section in plan context")
}
if !strings.Contains(ctx, "go test") {
t.Error("expected test command in Commands section")
}
if !strings.Contains(ctx, "golangci-lint") {
t.Error("expected lint command in Commands section")
}
// Context should be included
if !strings.Contains(ctx, "Debian Bookworm") {
t.Error("expected Context section")
}
}
func TestGetMemoryContext_PlanActive_SuppressesDailyNotes(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
// Write a daily note
ms.AppendToday("Today's note")
// Without plan, daily notes should appear
ctx := ms.GetMemoryContext()
if !strings.Contains(ctx, "Recent Daily Notes") {
t.Error("expected daily notes when no plan active")
}
// With plan, daily notes should be suppressed
ms.WriteLongTerm(testPlanExecuting)
ctx = ms.GetMemoryContext()
if strings.Contains(ctx, "Recent Daily Notes") {
t.Error("expected daily notes to be suppressed when plan is active")
}
}
func TestGetMemoryContext_InterviewingMode(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
ms.WriteLongTerm(testPlanInterviewing)
ctx := ms.GetMemoryContext()
if !strings.Contains(ctx, "interviewing") {
t.Error("expected interviewing context")
}
if !strings.Contains(ctx, "Interview Guide") {
t.Error("expected interview guide in context")
}
}
func TestGetMemoryContext_ExecutingMode(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
ms.WriteLongTerm(testPlanExecuting)
ctx := ms.GetMemoryContext()
if !strings.Contains(ctx, "Active Plan") {
t.Error("expected active plan in context")
}
if !strings.Contains(ctx, "Current: Phase 2") {
t.Error("expected current phase in context")
}
}
func TestGetMemoryContext_RegularMemory(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
ms.WriteLongTerm("Some notes about projects")
ctx := ms.GetMemoryContext()
if !strings.Contains(ctx, "Long-term Memory") {
t.Error("expected regular long-term memory section")
}
if !strings.Contains(ctx, "Some notes about projects") {
t.Error("expected memory content")
}
}
func TestBuildInterviewSeed(t *testing.T) {
seed := BuildInterviewSeed("Deploy monitoring stack")
if !strings.Contains(seed, "# Active Plan") {
t.Error("expected '# Active Plan' header")
}
if !strings.Contains(seed, "Deploy monitoring stack") {
t.Error("expected task description")
}
if !strings.Contains(seed, "interviewing") {
t.Error("expected interviewing status")
}
if !strings.Contains(seed, "> Phase: 1") {
t.Error("expected Phase: 1")
}
}
func TestFormatPlanDisplay(t *testing.T) {
ms, cleanup := newTestMemoryStore(t)
defer cleanup()
// No plan
display := ms.FormatPlanDisplay()
if display != "No active plan." {
t.Errorf("expected 'No active plan.', got %q", display)
}
// With plan
ms.WriteLongTerm(testPlanExecuting)
display = ms.FormatPlanDisplay()
if !strings.Contains(display, "Set up server monitoring") {
t.Error("expected task name in display")
}
if !strings.Contains(display, "Phase 2/3") {
t.Error("expected phase count in display")
}
// Commands section should be visible
if !strings.Contains(display, "Commands:") {
t.Error("expected Commands section in display")
}
if !strings.Contains(display, "go test") {
t.Error("expected test command in display")
}
}
func TestMemoryStoreCreation(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "memory-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
ms := NewMemoryStore(tmpDir)
// Verify memory directory was created
memoryDir := filepath.Join(tmpDir, "memory")
if _, err := os.Stat(memoryDir); os.IsNotExist(err) {
t.Error("expected memory directory to be created")
}
// Verify memory file path
expectedFile := filepath.Join(memoryDir, "MEMORY.md")
if ms.memoryFile != expectedFile {
t.Errorf("expected memory file %q, got %q", expectedFile, ms.memoryFile)
}
}

View file

@ -43,6 +43,7 @@ func (c *cmd) Help(ctx context.Context, message telego.Message) error {
/session - Show usage statistics /session - Show usage statistics
/skills - List available skills /skills - List available skills
/skill <name> [message] - Invoke a skill /skill <name> [message] - Invoke a skill
/plan - Show or manage your plan
` `
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
ChatID: telego.ChatID{ID: message.Chat.ID}, ChatID: telego.ChatID{ID: message.Chat.ID},