diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 93effe893..b0a55dcce 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -396,6 +396,24 @@ func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo { return result } +func (p *agentLoopDataProvider) GetContextInfo() miniapp.ContextInfo { + workDir, planWorkDir, workspace, bootstrap := p.loop.GetContextInfo() + files := make([]miniapp.BootstrapFileInfo, len(bootstrap)) + for i, b := range bootstrap { + files[i] = miniapp.BootstrapFileInfo{Name: b.Name, Path: b.Path, Scope: b.Scope} + } + return miniapp.ContextInfo{ + WorkDir: workDir, + PlanWorkDir: planWorkDir, + Workspace: workspace, + Bootstrap: files, + } +} + +func (p *agentLoopDataProvider) GetSystemPrompt() string { + return p.loop.GetSystemPrompt() +} + func (p *agentLoopDataProvider) GetGitRepos() []miniapp.GitRepoSummary { if time.Since(p.gitReposCacheAt) < gitCacheTTL { return p.gitReposCache diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 715f3bc64..d1f376233 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -16,6 +16,7 @@ import ( type ContextBuilder struct { workspace string + workDir string // session-specific working directory (worktree or project subdir) skillsLoader *skills.SkillsLoader memory *MemoryStore tools *tools.ToolRegistry // Direct reference to tool registry @@ -49,6 +50,12 @@ func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) { cb.tools = registry } +// SetWorkDir sets the session-specific working directory (e.g., worktree path +// or project subdirectory). Bootstrap files found here take priority over workspace. +func (cb *ContextBuilder) SetWorkDir(dir string) { + cb.workDir = dir +} + // SetPeerNote sets the peer session awareness note for the current call. func (cb *ContextBuilder) SetPeerNote(note string) { cb.peerNote = note @@ -190,25 +197,88 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md return strings.Join(parts, "\n\n---\n\n") } -func (cb *ContextBuilder) LoadBootstrapFiles() string { - bootstrapFiles := []string{ - "AGENTS.md", - "SOUL.md", - "USER.md", - "IDENTITY.md", +// BootstrapFileInfo describes a resolved bootstrap file. +type BootstrapFileInfo struct { + Name string `json:"name"` + Path string `json:"path"` // empty = not found + Scope string `json:"scope"` // "project" or "global" +} + +// bootstrapFileSpec defines the search scope for each bootstrap file. +type bootstrapFileSpec struct { + Name string + Scope string // "project" = workDir→planWorkDir→workspace, "global" = workspace only +} + +var bootstrapSpecs = []bootstrapFileSpec{ + {Name: "AGENTS.md", Scope: "project"}, + {Name: "IDENTITY.md", Scope: "project"}, + {Name: "SOUL.md", Scope: "global"}, + {Name: "USER.md", Scope: "global"}, +} + +// bootstrapProjectDirs returns de-duplicated search directories for project-scoped files. +func (cb *ContextBuilder) bootstrapProjectDirs() []string { + seen := map[string]bool{} + var dirs []string + for _, d := range []string{cb.workDir, cb.memory.GetPlanWorkDir(), cb.workspace} { + if d != "" && !seen[d] { + seen[d] = true + dirs = append(dirs, d) + } } + return dirs +} + +func (cb *ContextBuilder) LoadBootstrapFiles() string { + projectDirs := cb.bootstrapProjectDirs() var sb strings.Builder - for _, filename := range bootstrapFiles { - filePath := filepath.Join(cb.workspace, filename) - if data, err := os.ReadFile(filePath); err == nil { - fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data) + for _, spec := range bootstrapSpecs { + var dirs []string + if spec.Scope == "global" { + dirs = []string{cb.workspace} + } else { + dirs = projectDirs + } + for _, dir := range dirs { + filePath := filepath.Join(dir, spec.Name) + if data, err := os.ReadFile(filePath); err == nil { + fmt.Fprintf(&sb, "## %s\n\n%s\n\n", spec.Name, data) + break + } } } return sb.String() } +// ResolveBootstrapPaths returns path resolution info for each bootstrap file +// using the same search logic as LoadBootstrapFiles. +func (cb *ContextBuilder) ResolveBootstrapPaths() []BootstrapFileInfo { + projectDirs := cb.bootstrapProjectDirs() + + result := make([]BootstrapFileInfo, 0, len(bootstrapSpecs)) + for _, spec := range bootstrapSpecs { + info := BootstrapFileInfo{Name: spec.Name, Scope: spec.Scope} + var dirs []string + if spec.Scope == "global" { + dirs = []string{cb.workspace} + } else { + dirs = projectDirs + } + for _, dir := range dirs { + filePath := filepath.Join(dir, spec.Name) + if _, err := os.Stat(filePath); err == nil { + info.Path = filePath + break + } + } + result = append(result, info) + } + return result +} + func (cb *ContextBuilder) BuildMessages( history []providers.Message, summary string, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 6996db788..4c2f8c4bc 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -811,6 +811,9 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt // 1. Update tool contexts al.updateToolContexts(agent, opts.Channel, opts.ChatID) + // 1a. Set session-specific working directory for bootstrap file lookup + agent.ContextBuilder.SetWorkDir(agent.EffectiveWorkspace(opts.SessionKey)) + // 1b. Inject peer session awareness into system prompt projectPath := agent.ContextBuilder.GetPlanWorkDir() if projectPath == "" { @@ -2456,6 +2459,19 @@ func (al *AgentLoop) GetSessionStats() *stats.Stats { return &s } +// GetContextInfo returns the bootstrap file resolution and directory context for the default agent. +func (al *AgentLoop) GetContextInfo() (workDir, planWorkDir, workspace string, bootstrap []BootstrapFileInfo) { + agent := al.registry.GetDefaultAgent() + if agent == nil { + return "", "", "", nil + } + workspace = agent.Workspace + planWorkDir = agent.ContextBuilder.GetPlanWorkDir() + workDir = agent.ContextBuilder.workDir + bootstrap = agent.ContextBuilder.ResolveBootstrapPaths() + return +} + // GetSystemPrompt returns the system prompt last sent to the LLM. // Falls back to building from current state if no LLM call has occurred yet. func (al *AgentLoop) GetSystemPrompt() string { diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go index 785434db5..e04b8a281 100644 --- a/pkg/miniapp/miniapp.go +++ b/pkg/miniapp/miniapp.go @@ -98,6 +98,21 @@ type GitChange struct { Path string `json:"path"` } +// BootstrapFileInfo describes a resolved bootstrap file for the context API. +type BootstrapFileInfo struct { + Name string `json:"name"` + Path string `json:"path"` + Scope string `json:"scope"` +} + +// ContextInfo describes the agent's directory context and bootstrap file resolution. +type ContextInfo struct { + WorkDir string `json:"work_dir"` + PlanWorkDir string `json:"plan_work_dir"` + Workspace string `json:"workspace"` + Bootstrap []BootstrapFileInfo `json:"bootstrap"` +} + // DataProvider is the read-only interface to agent state for the Mini App API. type DataProvider interface { ListSkills() []skills.SkillInfo @@ -106,6 +121,8 @@ type DataProvider interface { GetActiveSessions() []SessionInfo GetGitRepos() []GitRepoSummary GetGitRepoDetail(name string) GitInfo + GetContextInfo() ContextInfo + GetSystemPrompt() string } // CommandSender injects a command into the message bus on behalf of a user. @@ -516,6 +533,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/miniapp/api/session", h.requireAuth(h.apiSession)) mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions)) mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand)) + mux.HandleFunc("/miniapp/api/context", h.requireAuth(h.apiContext)) + mux.HandleFunc("/miniapp/api/prompt", h.requireAuth(h.apiPrompt)) mux.HandleFunc("/miniapp/api/git", h.requireAuth(h.apiGit)) mux.HandleFunc("/miniapp/api/dev", h.requireAuth(h.apiDev)) mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents)) @@ -604,6 +623,14 @@ func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, s) } +func (h *Handler) apiContext(w http.ResponseWriter, r *http.Request) { + writeJSON(w, h.provider.GetContextInfo()) +} + +func (h *Handler) apiPrompt(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]string{"prompt": h.provider.GetSystemPrompt()}) +} + func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) { repo := r.URL.Query().Get("repo") if repo == "" { @@ -759,7 +786,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) { ch := h.notifier.Subscribe() defer h.notifier.Unsubscribe(ch) - var lastPlan, lastSession, lastSkills, lastDev []byte + var lastPlan, lastSession, lastSkills, lastDev, lastContext []byte // Send initial state immediately sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan) @@ -768,6 +795,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) { &lastSession) sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills) sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev) + sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext) for { select { @@ -782,6 +810,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) { &lastSession) sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills) sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev) + sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext) } } } diff --git a/pkg/miniapp/miniapp_test.go b/pkg/miniapp/miniapp_test.go index 6085ab1e3..642bc7918 100644 --- a/pkg/miniapp/miniapp_test.go +++ b/pkg/miniapp/miniapp_test.go @@ -190,6 +190,12 @@ func (m *mockDataProvider) GetGitRepos() []GitRepoSummary { func (m *mockDataProvider) GetGitRepoDetail(name string) GitInfo { return GitInfo{Name: name} } +func (m *mockDataProvider) GetContextInfo() ContextInfo { + return ContextInfo{Workspace: "/mock/workspace"} +} +func (m *mockDataProvider) GetSystemPrompt() string { + return "mock system prompt" +} type mockSender struct{} @@ -391,8 +397,8 @@ func TestSSE_NotifyDrivesSubsequentEvents(t *testing.T) { defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) - // Drain initial 4 events (plan, session, skills, dev) - drainEvents(t, scanner, 4, 2*time.Second) + // Drain initial 5 events (plan, session, skills, dev, context) + drainEvents(t, scanner, 5, 2*time.Second) // Mutate state and notify — diff dedup should detect the change and send a new event provider.mutated.Store(true) @@ -420,8 +426,8 @@ func TestSSE_DiffDedupSuppressesDuplicate(t *testing.T) { defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) - // Drain initial events (plan, session, skills, dev) - drainEvents(t, scanner, 4, 2*time.Second) + // Drain initial events (plan, session, skills, dev, context) + drainEvents(t, scanner, 5, 2*time.Second) // Notify with unchanged data — should produce zero new event lines notifier.Notify() @@ -474,6 +480,12 @@ func (m *mutatingDataProvider) GetGitRepos() []GitRepoSummary { func (m *mutatingDataProvider) GetGitRepoDetail(name string) GitInfo { return GitInfo{Name: name} } +func (m *mutatingDataProvider) GetContextInfo() ContextInfo { + return ContextInfo{Workspace: "/mock/workspace"} +} +func (m *mutatingDataProvider) GetSystemPrompt() string { + return "mock system prompt" +} // ── Dev proxy tests ── @@ -1678,8 +1690,8 @@ func TestSSE_DevEventUpdatesOnActivateDeactivate(t *testing.T) { defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) - // Drain initial 4 events - drainEvents(t, scanner, 4, 2*time.Second) + // Drain initial 5 events + drainEvents(t, scanner, 5, 2*time.Second) // Activate — should trigger a dev event with active=true h.ActivateDevTarget(id) diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index 2353c278f..93f1c9e38 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -797,6 +797,7 @@