feat: add context/prompt API and bootstrap file resolution to Mini App

- ContextBuilder: workDir field, SetWorkDir(), ResolveBootstrapPaths()
  with project-scoped search (workDir → planWorkDir → workspace)
- Mini App: /api/context and /api/prompt endpoints, SSE context events
- DataProvider: GetContextInfo() and GetSystemPrompt() interface methods
- loop.go: SetWorkDir per session, GetContextInfo() delegation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-24 17:40:53 +09:00
parent b43e797274
commit 62058922df
6 changed files with 231 additions and 18 deletions

View file

@ -396,6 +396,24 @@ func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo {
return result 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 { func (p *agentLoopDataProvider) GetGitRepos() []miniapp.GitRepoSummary {
if time.Since(p.gitReposCacheAt) < gitCacheTTL { if time.Since(p.gitReposCacheAt) < gitCacheTTL {
return p.gitReposCache return p.gitReposCache

View file

@ -16,6 +16,7 @@ import (
type ContextBuilder struct { type ContextBuilder struct {
workspace string workspace string
workDir string // session-specific working directory (worktree or project subdir)
skillsLoader *skills.SkillsLoader skillsLoader *skills.SkillsLoader
memory *MemoryStore memory *MemoryStore
tools *tools.ToolRegistry // Direct reference to tool registry tools *tools.ToolRegistry // Direct reference to tool registry
@ -49,6 +50,12 @@ func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
cb.tools = registry 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. // SetPeerNote sets the peer session awareness note for the current call.
func (cb *ContextBuilder) SetPeerNote(note string) { func (cb *ContextBuilder) SetPeerNote(note string) {
cb.peerNote = note 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") return strings.Join(parts, "\n\n---\n\n")
} }
func (cb *ContextBuilder) LoadBootstrapFiles() string { // BootstrapFileInfo describes a resolved bootstrap file.
bootstrapFiles := []string{ type BootstrapFileInfo struct {
"AGENTS.md", Name string `json:"name"`
"SOUL.md", Path string `json:"path"` // empty = not found
"USER.md", Scope string `json:"scope"` // "project" or "global"
"IDENTITY.md", }
// 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 var sb strings.Builder
for _, filename := range bootstrapFiles { for _, spec := range bootstrapSpecs {
filePath := filepath.Join(cb.workspace, filename) 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 { if data, err := os.ReadFile(filePath); err == nil {
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data) fmt.Fprintf(&sb, "## %s\n\n%s\n\n", spec.Name, data)
break
}
} }
} }
return sb.String() 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( func (cb *ContextBuilder) BuildMessages(
history []providers.Message, history []providers.Message,
summary string, summary string,

View file

@ -811,6 +811,9 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
// 1. Update tool contexts // 1. Update tool contexts
al.updateToolContexts(agent, opts.Channel, opts.ChatID) 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 // 1b. Inject peer session awareness into system prompt
projectPath := agent.ContextBuilder.GetPlanWorkDir() projectPath := agent.ContextBuilder.GetPlanWorkDir()
if projectPath == "" { if projectPath == "" {
@ -2456,6 +2459,19 @@ func (al *AgentLoop) GetSessionStats() *stats.Stats {
return &s 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. // GetSystemPrompt returns the system prompt last sent to the LLM.
// Falls back to building from current state if no LLM call has occurred yet. // Falls back to building from current state if no LLM call has occurred yet.
func (al *AgentLoop) GetSystemPrompt() string { func (al *AgentLoop) GetSystemPrompt() string {

View file

@ -98,6 +98,21 @@ type GitChange struct {
Path string `json:"path"` 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. // DataProvider is the read-only interface to agent state for the Mini App API.
type DataProvider interface { type DataProvider interface {
ListSkills() []skills.SkillInfo ListSkills() []skills.SkillInfo
@ -106,6 +121,8 @@ type DataProvider interface {
GetActiveSessions() []SessionInfo GetActiveSessions() []SessionInfo
GetGitRepos() []GitRepoSummary GetGitRepos() []GitRepoSummary
GetGitRepoDetail(name string) GitInfo GetGitRepoDetail(name string) GitInfo
GetContextInfo() ContextInfo
GetSystemPrompt() string
} }
// CommandSender injects a command into the message bus on behalf of a user. // 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/session", h.requireAuth(h.apiSession))
mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions)) mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions))
mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand)) 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/git", h.requireAuth(h.apiGit))
mux.HandleFunc("/miniapp/api/dev", h.requireAuth(h.apiDev)) mux.HandleFunc("/miniapp/api/dev", h.requireAuth(h.apiDev))
mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents)) 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) 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) { func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) {
repo := r.URL.Query().Get("repo") repo := r.URL.Query().Get("repo")
if repo == "" { if repo == "" {
@ -759,7 +786,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
ch := h.notifier.Subscribe() ch := h.notifier.Subscribe()
defer h.notifier.Unsubscribe(ch) defer h.notifier.Unsubscribe(ch)
var lastPlan, lastSession, lastSkills, lastDev []byte var lastPlan, lastSession, lastSkills, lastDev, lastContext []byte
// Send initial state immediately // Send initial state immediately
sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan) sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan)
@ -768,6 +795,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
&lastSession) &lastSession)
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills) sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev) sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev)
sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext)
for { for {
select { select {
@ -782,6 +810,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
&lastSession) &lastSession)
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills) sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev) sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev)
sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext)
} }
} }
} }

View file

@ -190,6 +190,12 @@ func (m *mockDataProvider) GetGitRepos() []GitRepoSummary {
func (m *mockDataProvider) GetGitRepoDetail(name string) GitInfo { func (m *mockDataProvider) GetGitRepoDetail(name string) GitInfo {
return GitInfo{Name: name} 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{} type mockSender struct{}
@ -391,8 +397,8 @@ func TestSSE_NotifyDrivesSubsequentEvents(t *testing.T) {
defer resp.Body.Close() defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
// Drain initial 4 events (plan, session, skills, dev) // Drain initial 5 events (plan, session, skills, dev, context)
drainEvents(t, scanner, 4, 2*time.Second) drainEvents(t, scanner, 5, 2*time.Second)
// Mutate state and notify — diff dedup should detect the change and send a new event // Mutate state and notify — diff dedup should detect the change and send a new event
provider.mutated.Store(true) provider.mutated.Store(true)
@ -420,8 +426,8 @@ func TestSSE_DiffDedupSuppressesDuplicate(t *testing.T) {
defer resp.Body.Close() defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
// Drain initial events (plan, session, skills, dev) // Drain initial events (plan, session, skills, dev, context)
drainEvents(t, scanner, 4, 2*time.Second) drainEvents(t, scanner, 5, 2*time.Second)
// Notify with unchanged data — should produce zero new event lines // Notify with unchanged data — should produce zero new event lines
notifier.Notify() notifier.Notify()
@ -474,6 +480,12 @@ func (m *mutatingDataProvider) GetGitRepos() []GitRepoSummary {
func (m *mutatingDataProvider) GetGitRepoDetail(name string) GitInfo { func (m *mutatingDataProvider) GetGitRepoDetail(name string) GitInfo {
return GitInfo{Name: name} 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 ── // ── Dev proxy tests ──
@ -1678,8 +1690,8 @@ func TestSSE_DevEventUpdatesOnActivateDeactivate(t *testing.T) {
defer resp.Body.Close() defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
// Drain initial 4 events // Drain initial 5 events
drainEvents(t, scanner, 4, 2*time.Second) drainEvents(t, scanner, 5, 2*time.Second)
// Activate — should trigger a dev event with active=true // Activate — should trigger a dev event with active=true
h.ActivateDevTarget(id) h.ActivateDevTarget(id)

View file

@ -797,6 +797,7 @@
<div id="session" class="panel"> <div id="session" class="panel">
<div class="loading" id="session-loading">Loading session...</div> <div class="loading" id="session-loading">Loading session...</div>
<div id="session-content" class="hidden"></div> <div id="session-content" class="hidden"></div>
<div id="context-content"></div>
</div> </div>
<div id="config" class="panel"> <div id="config" class="panel">
@ -1310,15 +1311,79 @@ function renderSessionFromData(sessions, stats) {
el.innerHTML = html; el.innerHTML = html;
} }
var cachedContextInfo = null;
function renderContextCard(ctx) {
if (!ctx) return '';
cachedContextInfo = ctx;
var wd = ctx.work_dir || '\u2014';
var pwd = ctx.plan_work_dir || '\u2014';
var ws = ctx.workspace || '\u2014';
var filesHtml = '';
if (ctx.bootstrap && ctx.bootstrap.length) {
filesHtml = ctx.bootstrap.map(function(b) {
var path = b.path ? escapeHtml(b.path) : '\u2014';
var scope = b.scope === 'global' ? 'global' : 'project';
var found = b.path ? 'var(--text)' : 'var(--hint)';
return `<div style="display:flex;gap:8px;padding:2px 0;font-size:12px">
<span style="min-width:90px;font-weight:600;color:${found}">${escapeHtml(b.name)}</span>
<span style="color:var(--hint);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${path}">${path}</span>
<span style="color:var(--hint);font-size:11px">${scope}</span>
</div>`;
}).join('');
}
return `<div class="card glass">
<div class="card-title">Context</div>
<div style="font-size:12px">
<div class="stat-row"><span class="stat-label">workDir</span><span class="stat-value" style="font-size:12px;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(wd)}">${escapeHtml(wd)}</span></div>
<div class="stat-row"><span class="stat-label">planWorkDir</span><span class="stat-value" style="font-size:12px;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(pwd)}">${escapeHtml(pwd)}</span></div>
<div class="stat-row"><span class="stat-label">workspace</span><span class="stat-value" style="font-size:12px;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(ws)}">${escapeHtml(ws)}</span></div>
</div>
<div style="margin-top:8px">${filesHtml}</div>
<div style="margin-top:8px;text-align:center">
<button onclick="toggleSystemPrompt()" style="background:var(--secondary-bg);color:var(--text);border:none;padding:6px 12px;border-radius:8px;font-size:12px;cursor:pointer" id="prompt-toggle-btn">Show System Prompt</button>
</div>
<pre id="system-prompt-view" style="display:none;margin-top:8px;font-size:11px;max-height:400px;overflow:auto;background:var(--secondary-bg);padding:8px;border-radius:6px;white-space:pre-wrap;word-break:break-word"></pre>
</div>`;
}
function toggleSystemPrompt() {
var view = document.getElementById('system-prompt-view');
var btn = document.getElementById('prompt-toggle-btn');
if (!view || !btn) return;
if (view.style.display === 'none') {
btn.textContent = 'Loading...';
apiFetch('/miniapp/api/prompt').then(function(data) {
view.textContent = data.prompt || '(empty)';
view.style.display = 'block';
btn.textContent = 'Hide System Prompt';
}).catch(function() {
btn.textContent = 'Show System Prompt';
});
} else {
view.style.display = 'none';
btn.textContent = 'Show System Prompt';
}
}
function renderContextFromData(ctx) {
var el = document.getElementById('context-content');
if (el) el.innerHTML = renderContextCard(ctx);
}
function loadSession() { function loadSession() {
return loadTab('session-loading', 'session-content', 'session', return loadTab('session-loading', 'session-content', 'session',
function() { function() {
return Promise.all([ return Promise.all([
apiFetch('/miniapp/api/session'), apiFetch('/miniapp/api/session'),
apiFetch('/miniapp/api/sessions').catch(function() { return []; }), apiFetch('/miniapp/api/sessions').catch(function() { return []; }),
apiFetch('/miniapp/api/context').catch(function() { return null; }),
]); ]);
}, },
function(results) { renderSessionFromData(results[1], results[0]); }); function(results) {
renderSessionFromData(results[1], results[0]);
renderContextFromData(results[2]);
});
} }
function formatTokens(n) { function formatTokens(n) {
@ -1537,6 +1602,9 @@ function connectSSE() {
eventSource.addEventListener('dev', function(e) { eventSource.addEventListener('dev', function(e) {
try { lastSSE.dev = Date.now(); renderDevFromData(JSON.parse(e.data)); } catch(err) {} try { lastSSE.dev = Date.now(); renderDevFromData(JSON.parse(e.data)); } catch(err) {}
}); });
eventSource.addEventListener('context', function(e) {
try { renderContextFromData(JSON.parse(e.data)); } catch(err) {}
});
eventSource.onerror = function() { eventSource.onerror = function() {
// Browser will auto-reconnect EventSource // Browser will auto-reconnect EventSource
}; };