feat: show MEMORY.md raw content in Plan tab during interview/review

During interviewing/review status, the Plan tab now displays the raw
MEMORY.md content with simple markdown rendering instead of the
structured phase/step list. This lets users verify their input is being
captured in real-time. Switches back to structured view on executing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-22 12:09:46 +09:00
parent 5087554eec
commit ceaafd8e30
4 changed files with 182 additions and 14 deletions

View file

@ -306,7 +306,7 @@ func (p *agentLoopDataProvider) ListSkills() []skills.SkillInfo {
} }
func (p *agentLoopDataProvider) GetPlanInfo() miniapp.PlanInfo { func (p *agentLoopDataProvider) GetPlanInfo() miniapp.PlanInfo {
hasPlan, status, currentPhase, totalPhases, display := p.loop.GetPlanInfo() hasPlan, status, currentPhase, totalPhases, display, memory := p.loop.GetPlanInfo()
// Convert agent.PlanPhase → miniapp.PlanPhase // Convert agent.PlanPhase → miniapp.PlanPhase
agentPhases := p.loop.GetPlanPhases() agentPhases := p.loop.GetPlanPhases()
@ -334,6 +334,7 @@ func (p *agentLoopDataProvider) GetPlanInfo() miniapp.PlanInfo {
TotalPhases: totalPhases, TotalPhases: totalPhases,
Display: display, Display: display,
Phases: phases, Phases: phases,
Memory: memory,
} }
} }

View file

@ -960,6 +960,13 @@ func buildTaskReminder(userMessage string, lastBlocker string) providers.Message
} }
} }
// interviewRejectMessage is the fixed rejection text injected when tool calls
// are blocked during the interview phase. It is deliberately short to avoid
// wasting tokens, and ends with a purpose reminder to steer the LLM back.
const interviewRejectMessage = "[System] Tool call rejected. " +
"You are in interview mode — ask the user questions and update MEMORY.md. " +
"Do not execute, edit, or write project files."
// buildPlanReminder returns a reminder message for plan pre-execution states // buildPlanReminder returns a reminder message for plan pre-execution states
// (interviewing / review) to keep the AI focused on the interview workflow // (interviewing / review) to keep the AI focused on the interview workflow
// during tool-call iterations. // during tool-call iterations.
@ -1594,6 +1601,39 @@ func (al *AgentLoop) runLLMIteration(
normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc))
} }
// --- Interview mode: reject disallowed tool calls before they
// enter messages or session history. Rejected calls are stripped
// from normalizedToolCalls so they never reach the assistant
// message, the tool-result list, or the session store.
// A single compact rejection message is injected instead.
var interviewRejected []string
if isPlanPreExecution(agent.ContextBuilder.GetPlanStatus()) {
allowed := normalizedToolCalls[:0] // reuse backing array
for _, tc := range normalizedToolCalls {
if isToolAllowedDuringInterview(tc.Name, tc.Arguments) {
allowed = append(allowed, tc)
} else {
interviewRejected = append(interviewRejected, tc.Name)
}
}
normalizedToolCalls = allowed
if len(interviewRejected) > 0 {
logger.InfoCF("agent", "Interview mode: rejected tool calls",
map[string]any{
"agent_id": agent.ID,
"rejected": interviewRejected,
})
messages = append(messages, providers.Message{
Role: "user",
Content: interviewRejectMessage,
})
}
// If all tool calls were rejected, skip to next iteration.
if len(normalizedToolCalls) == 0 {
continue
}
}
// Log tool calls // Log tool calls
toolNames := make([]string, 0, len(normalizedToolCalls)) toolNames := make([]string, 0, len(normalizedToolCalls))
for _, tc := range normalizedToolCalls { for _, tc := range normalizedToolCalls {
@ -1734,15 +1774,8 @@ func (al *AgentLoop) runLLMIteration(
} }
} }
// Block non-allowed tools during plan interview mode.
// Only read-type tools and MEMORY.md writes are permitted.
toolStart := time.Now() toolStart := time.Now()
var toolResult *tools.ToolResult toolResult := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
if isPlanPreExecution(agent.ContextBuilder.GetPlanStatus()) && !isToolAllowedDuringInterview(tc.Name, tc.Arguments) {
toolResult = tools.ErrorResult("Interview mode: only read tools and MEMORY.md edits are allowed. Focus on asking questions and updating the plan.")
} else {
toolResult = agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
}
toolDuration := time.Since(toolStart) toolDuration := time.Since(toolStart)
// Update tool log entry with result // Update tool log entry with result
@ -2016,20 +2049,21 @@ func (al *AgentLoop) ListSkills() []skills.SkillInfo {
} }
// GetPlanInfo returns plan state from the default agent's memory store. // GetPlanInfo returns plan state from the default agent's memory store.
func (al *AgentLoop) GetPlanInfo() (hasPlan bool, status string, currentPhase, totalPhases int, display string) { func (al *AgentLoop) GetPlanInfo() (hasPlan bool, status string, currentPhase, totalPhases int, display string, memory string) {
agent := al.registry.GetDefaultAgent() agent := al.registry.GetDefaultAgent()
if agent == nil { if agent == nil {
return false, "", 0, 0, "No agent available." return false, "", 0, 0, "No agent available.", ""
} }
mem := agent.ContextBuilder.Memory() mem := agent.ContextBuilder.Memory()
if mem == nil { if mem == nil {
return false, "", 0, 0, "No memory store." return false, "", 0, 0, "No memory store.", ""
} }
hasPlan = mem.HasActivePlan() hasPlan = mem.HasActivePlan()
status = mem.GetPlanStatus() status = mem.GetPlanStatus()
currentPhase = mem.GetCurrentPhase() currentPhase = mem.GetCurrentPhase()
totalPhases = mem.GetTotalPhases() totalPhases = mem.GetTotalPhases()
display = mem.FormatPlanDisplay() display = mem.FormatPlanDisplay()
memory = mem.ReadLongTerm()
return return
} }

View file

@ -45,6 +45,7 @@ type PlanInfo struct {
TotalPhases int `json:"total_phases"` TotalPhases int `json:"total_phases"`
Display string `json:"display"` Display string `json:"display"`
Phases []PlanPhase `json:"phases"` Phases []PlanPhase `json:"phases"`
Memory string `json:"memory"`
} }
// SessionInfo represents an active session entry for the API response. // SessionInfo represents an active session entry for the API response.

View file

@ -403,6 +403,96 @@
border-color: var(--btn); border-color: var(--btn);
} }
/* MEMORY.md raw display */
.memory-view {
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
padding: 14px 16px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 16px;
box-shadow: 0 1px 3px var(--glass-shadow);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
.memory-view .md-h1 {
font-size: 18px;
font-weight: 700;
margin: 16px 0 8px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.memory-view .md-h1:first-child { margin-top: 0; }
.memory-view .md-h2 {
font-size: 15px;
font-weight: 700;
margin: 14px 0 6px;
color: var(--link);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.memory-view .md-h3 {
font-size: 14px;
font-weight: 600;
margin: 12px 0 4px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.memory-view .md-checkbox {
margin: 2px 0;
}
.memory-view .md-checkbox-icon {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 4px;
border: 1.5px solid var(--hint);
vertical-align: middle;
margin-right: 6px;
position: relative;
top: -1px;
}
.memory-view .md-checkbox-icon.checked {
background: var(--done);
border-color: var(--done);
}
.memory-view .md-checkbox-icon.checked::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 4px;
height: 8px;
border: solid #fff;
border-width: 0 1.5px 1.5px 0;
transform: rotate(45deg);
}
.memory-view .md-quote {
border-left: 3px solid var(--hint);
padding-left: 10px;
color: var(--hint);
margin: 4px 0;
}
.memory-view .md-bullet {
margin: 2px 0;
padding-left: 12px;
text-indent: -12px;
}
.memory-view .md-bullet::before {
content: '\2022 ';
color: var(--hint);
}
</style> </style>
</head> </head>
<body> <body>
@ -584,13 +674,55 @@ function renderPlanFromData(data) {
'<div style="color:var(--hint);margin-top:4px">Phase ' + data.current_phase + ' / ' + data.total_phases + '</div>' + '<div style="color:var(--hint);margin-top:4px">Phase ' + data.current_phase + ' / ' + data.total_phases + '</div>' +
'</div>'; '</div>';
if (data.phases && data.phases.length > 0) { if (data.status === 'interviewing' || data.status === 'review') {
html += renderPhases(data.phases, data.current_phase); // Show raw MEMORY.md content during interview/review
if (data.memory) {
html += '<div class="memory-view">' + renderSimpleMarkdown(data.memory) + '</div>';
}
} else {
// Show structured phase/step list during executing
if (data.phases && data.phases.length > 0) {
html += renderPhases(data.phases, data.current_phase);
}
} }
el.innerHTML = html; el.innerHTML = html;
} }
function renderSimpleMarkdown(text) {
var lines = text.split('\n');
var out = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
// Headings
if (/^### /.test(line)) {
out.push('<div class="md-h3">' + escapeHtml(line.slice(4)) + '</div>');
} else if (/^## /.test(line)) {
out.push('<div class="md-h2">' + escapeHtml(line.slice(3)) + '</div>');
} else if (/^# /.test(line)) {
out.push('<div class="md-h1">' + escapeHtml(line.slice(2)) + '</div>');
// Checkboxes
} else if (/^- \[x\] /.test(line)) {
out.push('<div class="md-checkbox"><span class="md-checkbox-icon checked"></span>' + escapeHtml(line.slice(6)) + '</div>');
} else if (/^- \[ \] /.test(line)) {
out.push('<div class="md-checkbox"><span class="md-checkbox-icon"></span>' + escapeHtml(line.slice(6)) + '</div>');
// Blockquote
} else if (/^> /.test(line)) {
out.push('<div class="md-quote">' + escapeHtml(line.slice(2)) + '</div>');
// Bullet list
} else if (/^- /.test(line)) {
out.push('<div class="md-bullet">' + escapeHtml(line.slice(2)) + '</div>');
// Empty line
} else if (line.trim() === '') {
out.push('<br>');
// Plain text
} else {
out.push('<div>' + escapeHtml(line) + '</div>');
}
}
return out.join('');
}
async function loadPlan() { async function loadPlan() {
var loading = document.getElementById('plan-loading'); var loading = document.getElementById('plan-loading');
var el = document.getElementById('plan-content'); var el = document.getElementById('plan-content');