feat: research interval scheduling + heartbeat web_search quota (#47)

* feat: research heartbeat incremental progress + autonomous recall/forget

- Heartbeat auto-detects active/pending research tasks and injects
  incremental research instructions (1-2 findings per cycle)
- Guard prevents premature completion during heartbeat (min 5 findings)
- Research tool gains recall/forget actions for autonomous context control
- System prompt always shows lightweight research catalog via RuntimeStatus
- Single-topic focus limit to avoid context pollution
- FocusTracker shared between tool and Mini App for unified state
- Mini App research tab: Recall/Forget buttons + focused badge
- SearchTasks added for title/slug partial match queries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: research interval scheduling + heartbeat web_search quota (#46)

* feat: research task interval scheduling + heartbeat web_search quota

- Add per-task research interval (default 24h) with `interval` and
  `last_researched_at` columns on research_tasks (auto-migrated)
- Heartbeat only picks tasks that are "due" via ListDueTasks()
- Add heartbeat-scoped web_search quota (default 3/heartbeat) enforced
  in WebSearchTool.Execute() via atomic counter in context
- Support ParseInterval() with "d" suffix (e.g. "1d", "7d")
- Touch last_researched_at on add_finding success
- Add set_interval action to research tool + Mini App API
- Expose interval/last_researched_at in all API responses

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: display research interval and last_researched_at in Mini App & Web UI

- Mini App: show interval and last researched date in task list and detail
- Web Frontend: add interval/last_researched_at to ResearchTask interface,
  task cards, and detail page
- Add i18n keys for en/zh locales
- Update createResearchTask and researchTaskAction API signatures

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: lint issues (gci, gofumpt, golines, predeclared)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: Mini App interval selector UI for research tasks

Add dropdown selector to change research interval (30m/1h/6h/12h/1d/3d/7d)
directly from the task detail view. Changes are saved immediately via
the set_interval API action.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: Mini App status transition buttons (activate/complete/cancel/reopen)

- Add activate (pending→active) and complete (active→completed) buttons
- Backend: add 'activate' and 'complete' actions to task action handler
- Buttons show contextually based on current task status

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: normalize interval display (24h→1d) and default to 1d

- DefaultResearchInterval changed from '24h' to '1d'
- SQLite migration default also '1d'
- Mini App normalizes '24h' to '1d' for display and selector matching

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: migrate existing '24h' intervals to '1d' on startup

Normalizes legacy data where the old default '24h' was written
before the switch to '1d'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: refresh task detail after interval change in Mini App

researchSetInterval was not calling openResearchTask() after the API
call, so the UI didn't reflect the change until manual navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use addEventListener for interval select in Mini App

Inline onchange on <select> is unreliable in Telegram WebView.
Switch to addEventListener('change') bound after innerHTML render.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-16 17:00:43 +09:00 committed by GitHub
parent 464a614af2
commit 876e7e8911
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1142 additions and 91 deletions

View file

@ -63,6 +63,8 @@ const (
type gatewayServices struct {
CronService *cron.CronService
HeartbeatService *heartbeat.HeartbeatService
ResearchStore *research.ResearchStore
ResearchFocus *research.FocusTracker
MediaStore media.MediaStore
ChannelManager *channels.Manager
DeviceService *devices.Service
@ -340,8 +342,13 @@ func setupAndStartServices(
if rsErr != nil {
logger.ErrorCF("research", "Failed to open research store", map[string]any{"error": rsErr.Error()})
} else {
agentLoop.RegisterTool(tools.NewResearchTool(researchStore, cfg.WorkspacePath()))
focusTracker := research.NewFocusTracker()
agentLoop.RegisterTool(tools.NewResearchTool(researchStore, cfg.WorkspacePath(), focusTracker))
handler.SetResearchStore(researchStore)
handler.SetResearchFocus(focusTracker)
services.ResearchStore = researchStore
services.ResearchFocus = focusTracker
services.HeartbeatService.SetResearchStore(researchStore)
fmt.Println("✓ Research store initialized")
}
@ -547,6 +554,9 @@ func restartServices(
cfg.Heartbeat.Enabled,
)
services.HeartbeatService.SetBus(msgBus)
if services.ResearchStore != nil {
services.HeartbeatService.SetResearchStore(services.ResearchStore)
}
services.HeartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
if channel == "" || chatID == "" {
channel, chatID = "cli", "direct"

View file

@ -25,6 +25,7 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/research"
"github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/state"
@ -911,6 +912,9 @@ func (al *AgentLoop) ProcessDirectWithChannel(
// ProcessHeartbeat processes a heartbeat request without session history.
// Each heartbeat is independent and doesn't accumulate context.
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) {
ctx = tools.WithHeartbeatContext(ctx)
ctx = tools.WithWebSearchQuota(ctx, research.DefaultHeartbeatSearchQuota)
agent := al.GetRegistry().GetDefaultAgent()
if agent == nil {
return "", fmt.Errorf("no default agent for heartbeat")

View file

@ -18,6 +18,7 @@ import (
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/research"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
)
@ -39,6 +40,7 @@ type HeartbeatService struct {
bus *bus.MessageBus
state *state.Manager
handler HeartbeatHandler
researchStore *research.ResearchStore
interval time.Duration
enabled bool
mu sync.RWMutex
@ -87,6 +89,13 @@ func (hs *HeartbeatService) SetHeartbeatThreadID(threadID int) {
hs.heartbeatThreadID = threadID
}
// SetResearchStore injects the research store for heartbeat-driven research.
func (hs *HeartbeatService) SetResearchStore(store *research.ResearchStore) {
hs.mu.Lock()
defer hs.mu.Unlock()
hs.researchStore = store
}
// ResetSuppression clears the notification suppression so the next
// non-silent heartbeat result will be delivered to the user again.
// Typically called when a user message arrives.
@ -321,6 +330,7 @@ func (hs *HeartbeatService) buildPrompt() string {
}
now := time.Now().Format("2006-01-02 15:04:05")
researchCtx := hs.buildResearchContext()
return fmt.Sprintf(`# Heartbeat Check
Current time: %s
@ -330,7 +340,95 @@ Review the following tasks and execute any necessary actions using available ski
If there is nothing that requires attention, respond ONLY with: HEARTBEAT_OK
%s
`, now, content)
%s`, now, content, researchCtx)
}
const (
maxResearchTasks = 3
maxFindingsPerTask = 10
maxSummaryLen = 200
)
// buildResearchContext generates a prompt section for incremental research progress.
// Only includes tasks that are "due" based on their interval setting.
func (hs *HeartbeatService) buildResearchContext() string {
hs.mu.RLock()
store := hs.researchStore
hs.mu.RUnlock()
if store == nil {
return ""
}
tasks, err := store.ListDueTasks(maxResearchTasks)
if err != nil {
hs.logErrorf("Failed to list due research tasks: %v", err)
return ""
}
if len(tasks) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n## Research Tasks (Incremental Progress)\n\n")
b.WriteString("You have research tasks due for progress. For each task below:\n")
b.WriteString("1. If pending, set status to 'active' first\n")
b.WriteString("2. Use web_search to find new information, then add_finding to record it\n")
b.WriteString(
fmt.Sprintf(
"3. **Web search budget: %d calls total this heartbeat** — use them wisely\n",
research.DefaultHeartbeatSearchQuota,
),
)
b.WriteString("4. Do NOT set status to 'completed' — research progresses incrementally across heartbeats\n\n")
for _, task := range tasks {
b.WriteString(fmt.Sprintf("### %s [%s] (id: %s)\n", task.Title, task.Status, task.ID))
b.WriteString(fmt.Sprintf("Interval: %s", task.Interval))
if !task.LastResearchedAt.IsZero() {
b.WriteString(fmt.Sprintf(" | Last researched: %s", task.LastResearchedAt.Format("2006-01-02 15:04")))
}
b.WriteString("\n")
if task.Description != "" {
b.WriteString(fmt.Sprintf("Description: %s\n", task.Description))
}
docs, err := store.ListDocuments(task.ID)
if err != nil {
b.WriteString("(error loading findings)\n\n")
continue
}
if len(docs) == 0 {
b.WriteString("No findings yet — start researching this topic.\n\n")
continue
}
shown := docs
if len(shown) > maxFindingsPerTask {
shown = shown[:maxFindingsPerTask]
}
b.WriteString(fmt.Sprintf("Existing findings (%d total):\n", len(docs)))
for _, d := range shown {
summary := d.Summary
if len(summary) > maxSummaryLen {
summary = summary[:maxSummaryLen] + "..."
}
b.WriteString(fmt.Sprintf("- [%d] %s", d.Seq, d.Title))
if summary != "" {
b.WriteString(fmt.Sprintf(" — %s", summary))
}
b.WriteString("\n")
}
if len(docs) > maxFindingsPerTask {
b.WriteString(fmt.Sprintf(" ... and %d more findings\n", len(docs)-maxFindingsPerTask))
}
b.WriteString("\nAdd NEW findings that build on and extend the above. Do not repeat existing findings.\n\n")
}
return b.String()
}
// createDefaultHeartbeatTemplate creates the default HEARTBEAT.md file

View file

@ -16,6 +16,11 @@ func (h *Handler) SetResearchStore(rs *research.ResearchStore) {
h.researchStore = rs
}
// SetResearchFocus injects the shared focus tracker for recall/forget control.
func (h *Handler) SetResearchFocus(ft *research.FocusTracker) {
h.researchFocus = ft
}
// apiResearch handles GET /miniapp/api/research (list) and POST (create).
func (h *Handler) apiResearch(w http.ResponseWriter, r *http.Request) {
if h.researchStore == nil {
@ -40,10 +45,13 @@ type researchTaskResponse struct {
Description string `json:"description"`
Status string `json:"status"`
OutputDir string `json:"output_dir"`
Interval string `json:"interval"`
LastResearchedAt string `json:"last_researched_at,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
CompletedAt string `json:"completed_at,omitempty"`
DocumentCount int `json:"document_count"`
Focused bool `json:"focused"`
}
func taskToResponse(t *research.Task, docCount int) researchTaskResponse {
@ -54,10 +62,14 @@ func taskToResponse(t *research.Task, docCount int) researchTaskResponse {
Description: t.Description,
Status: string(t.Status),
OutputDir: t.OutputDir,
Interval: t.Interval,
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z"),
DocumentCount: docCount,
}
if !t.LastResearchedAt.IsZero() {
resp.LastResearchedAt = t.LastResearchedAt.Format("2006-01-02T15:04:05Z")
}
if !t.CompletedAt.IsZero() {
resp.CompletedAt = t.CompletedAt.Format("2006-01-02T15:04:05Z")
}
@ -75,7 +87,11 @@ func (h *Handler) apiResearchList(w http.ResponseWriter, r *http.Request) {
result := make([]researchTaskResponse, 0, len(tasks))
for _, t := range tasks {
docCount, _ := h.researchStore.DocumentCount(t.ID)
result = append(result, taskToResponse(t, docCount))
resp := taskToResponse(t, docCount)
if h.researchFocus != nil {
resp.Focused = h.researchFocus.IsFocused(t.ID)
}
result = append(result, resp)
}
writeJSON(w, result)
}
@ -84,6 +100,7 @@ func (h *Handler) apiResearchCreate(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
Description string `json:"description"`
Interval string `json:"interval"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
@ -94,7 +111,11 @@ func (h *Handler) apiResearchCreate(w http.ResponseWriter, r *http.Request) {
return
}
task, err := h.researchStore.CreateTask(strings.TrimSpace(req.Title), strings.TrimSpace(req.Description))
task, err := h.researchStore.CreateTask(
strings.TrimSpace(req.Title),
strings.TrimSpace(req.Description),
strings.TrimSpace(req.Interval),
)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
return
@ -180,8 +201,13 @@ func (h *Handler) apiResearchGetTask(w http.ResponseWriter, taskID string) {
})
}
resp := taskToResponse(task, len(docs))
if h.researchFocus != nil {
resp.Focused = h.researchFocus.IsFocused(taskID)
}
writeJSON(w, researchTaskDetailResponse{
researchTaskResponse: taskToResponse(task, len(docs)),
researchTaskResponse: resp,
Documents: docResponses,
})
}
@ -191,6 +217,7 @@ func (h *Handler) apiResearchTaskAction(w http.ResponseWriter, r *http.Request,
Action string `json:"action"`
Title string `json:"title"`
Description string `json:"description"`
Interval string `json:"interval"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
@ -208,6 +235,16 @@ func (h *Handler) apiResearchTaskAction(w http.ResponseWriter, r *http.Request,
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
return
}
case "activate":
if err := h.researchStore.SetTaskStatus(taskID, research.StatusActive); err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
return
}
case "complete":
if err := h.researchStore.SetTaskStatus(taskID, research.StatusCompleted); err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
return
}
case "update":
task, err := h.researchStore.GetTask(taskID)
if err != nil {
@ -226,6 +263,15 @@ func (h *Handler) apiResearchTaskAction(w http.ResponseWriter, r *http.Request,
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
return
}
case "set_interval":
if req.Interval == "" {
http.Error(w, `{"error":"interval is required"}`, http.StatusBadRequest)
return
}
if err := h.researchStore.SetInterval(taskID, req.Interval); err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
return
}
default:
http.Error(w, `{"error":"unknown action"}`, http.StatusBadRequest)
return
@ -273,3 +319,65 @@ func (h *Handler) apiResearchGetDoc(w http.ResponseWriter, taskID, docID string)
"content": string(content),
})
}
// apiResearchFocus handles GET/POST /miniapp/api/research/focus.
// GET returns the current focus state; POST sets focus/unfocus for a task.
func (h *Handler) apiResearchFocus(w http.ResponseWriter, r *http.Request) {
if h.researchFocus == nil {
http.Error(w, `{"error":"research focus not available"}`, http.StatusServiceUnavailable)
return
}
switch r.Method {
case http.MethodGet:
h.apiResearchFocusGet(w)
case http.MethodPost:
h.apiResearchFocusSet(w, r)
default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
}
func (h *Handler) apiResearchFocusGet(w http.ResponseWriter) {
taskID, title := h.researchFocus.Current()
writeJSON(w, map[string]any{
"focused_id": taskID,
"focused_title": title,
})
}
func (h *Handler) apiResearchFocusSet(w http.ResponseWriter, r *http.Request) {
var req struct {
Action string `json:"action"` // "recall" or "forget"
TaskID string `json:"task_id"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
switch req.Action {
case "recall":
if req.TaskID == "" {
http.Error(w, `{"error":"task_id is required"}`, http.StatusBadRequest)
return
}
task, err := h.researchStore.GetTask(req.TaskID)
if err != nil {
http.Error(w, `{"error":"task not found"}`, http.StatusNotFound)
return
}
h.researchFocus.Focus(task.ID, task.Title)
case "forget":
if req.TaskID == "" {
h.researchFocus.UnfocusAll()
} else {
h.researchFocus.Unfocus(req.TaskID)
}
default:
http.Error(w, `{"error":"action must be recall or forget"}`, http.StatusBadRequest)
return
}
h.apiResearchFocusGet(w)
}

View file

@ -1381,15 +1381,22 @@ async function loadResearch() {
}
el.innerHTML = tasks.map(function(t) {
var sc = STATUS_COLORS[t.status] || STATUS_COLORS.pending;
return '<div class="card glass glass-interactive" style="cursor:pointer;padding:14px" onclick="openResearchTask(\'' + t.id + '\')">' +
var focusBadge = t.focused ? '<span style="font-size:10px;font-weight:600;padding:2px 6px;border-radius:8px;background:rgba(168,85,247,0.2);color:#a855f7">focused</span>' : '';
return '<div class="card glass glass-interactive" style="cursor:pointer;padding:14px' +
(t.focused ? ';border-left:3px solid #a855f7' : '') +
'" onclick="openResearchTask(\'' + t.id + '\')">' +
'<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">' +
'<span style="font-weight:600;font-size:15px">' + esc(t.title) + '</span>' +
'<div style="display:flex;gap:4px;align-items:center">' +
focusBadge +
'<span style="font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px;background:' +
sc.bg + ';color:' + sc.text + '">' + t.status + '</span>' +
'</div>' +
'</div>' +
(t.description ? '<div style="color:var(--hint);font-size:13px;margin-top:4px;line-height:1.4">' + esc(t.description).substring(0, 120) + '</div>' : '') +
'<div style="color:var(--hint);font-size:11px;margin-top:6px">' +
t.document_count + ' docs · ' + new Date(t.created_at).toLocaleDateString() +
t.document_count + ' docs · ⏱ ' + ((t.interval === '24h' ? '1d' : t.interval) || '1d') +
(t.last_researched_at ? ' · last: ' + new Date(t.last_researched_at).toLocaleDateString() : '') +
'</div>' +
'</div>';
}).join('');
@ -1429,16 +1436,36 @@ async function openResearchTask(id) {
if (task.description) {
html += '<div style="color:var(--hint);font-size:13px;line-height:1.5;margin-bottom:8px;white-space:pre-wrap">' + esc(task.description) + '</div>';
}
html += '<div style="color:var(--hint);font-size:11px">' +
var curInterval = task.interval || '24h';
if (curInterval === '24h') curInterval = '1d';
html += '<div style="color:var(--hint);font-size:11px;display:flex;align-items:center;gap:6px">' +
'<span>Interval:</span>' +
'<select id="research-interval-select" data-task-id="' + id + '" style="' +
'font-size:11px;padding:1px 4px;border-radius:6px;' +
'background:var(--tab-track-bg);color:var(--text);' +
'border:1px solid var(--glass-divider);outline:none' +
'">' +
['30m','1h','6h','12h','1d','3d','7d'].map(function(v) {
return '<option value="' + v + '"' + (v === curInterval ? ' selected' : '') + '>' + v + '</option>';
}).join('') +
'</select>' +
(task.last_researched_at ? '<span> · Last: ' + new Date(task.last_researched_at).toLocaleString() + '</span>' : '<span> · Not yet researched</span>') +
'</div>';
html += '<div style="color:var(--hint);font-size:11px;margin-top:2px">' +
'Created: ' + new Date(task.created_at).toLocaleString() +
(task.completed_at ? ' · Completed: ' + new Date(task.completed_at).toLocaleString() : '') +
'</div>';
if (canCancel || canReopen) {
html += '<div style="margin-top:10px;display:flex;gap:8px">';
if (task.focused) {
html += '<button class="worktree-btn dispose" onclick="researchSetFocus(\'' + id + '\',false)" style="background:rgba(168,85,247,0.15);color:#a855f7;border-color:#a855f7">Forget</button>';
} else {
html += '<button class="worktree-btn merge" onclick="researchSetFocus(\'' + id + '\',true)" style="background:rgba(168,85,247,0.15);color:#a855f7;border-color:#a855f7">Recall</button>';
}
if (task.status === 'pending') html += '<button class="worktree-btn merge" onclick="researchAction(\'' + id + '\',\'activate\')">Activate</button>';
if (task.status === 'active') html += '<button class="worktree-btn merge" onclick="researchAction(\'' + id + '\',\'complete\')" style="background:rgba(34,197,94,0.15);color:#22c55e;border-color:#22c55e">Complete</button>';
if (canCancel) html += '<button class="worktree-btn dispose" onclick="researchAction(\'' + id + '\',\'cancel\')">Cancel</button>';
if (canReopen) html += '<button class="worktree-btn merge" onclick="researchAction(\'' + id + '\',\'reopen\')">Reopen</button>';
html += '</div>';
}
html += '</div>';
// Documents
@ -1461,6 +1488,13 @@ async function openResearchTask(id) {
}).join('');
}
el.innerHTML = html;
// Bind interval select via addEventListener (inline onchange unreliable in Telegram WebView)
var sel = document.getElementById('research-interval-select');
if (sel) {
sel.addEventListener('change', function() {
researchSetInterval(sel.dataset.taskId, sel.value);
});
}
} catch (e) {
el.innerHTML = '<div class="empty-state">Failed to load task.</div>';
}
@ -1489,6 +1523,18 @@ async function toggleResearchDoc(card, taskId, docId) {
}
}
async function researchSetInterval(taskId, interval) {
try {
await fetch(
API_BASE + '/miniapp/api/research/' + taskId + '?initData=' + encodeURIComponent(initData),
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'set_interval', interval: interval }) }
);
openResearchTask(taskId);
} catch (e) {
// ignore
}
}
async function researchAction(taskId, action) {
try {
await fetch(
@ -1535,12 +1581,27 @@ async function createResearchTask() {
}
}
async function researchSetFocus(taskId, recall) {
try {
await fetch(
API_BASE + '/miniapp/api/research/focus?initData=' + encodeURIComponent(initData),
{ method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: recall ? 'recall' : 'forget', task_id: taskId }) }
);
// Refresh the detail view to show updated focus state
openResearchTask(taskId);
} catch (e) {
// ignore
}
}
window.showNewTaskForm = showNewTaskForm;
window.hideNewTaskForm = hideNewTaskForm;
window.createResearchTask = createResearchTask;
window.openResearchTask = openResearchTask;
window.toggleResearchDoc = toggleResearchDoc;
window.researchAction = researchAction;
window.researchSetFocus = researchSetFocus;
window.showResearchList = showResearchList;

View file

@ -41,6 +41,7 @@ type Handler struct {
workspace string
orchBroadcaster *orch.Broadcaster
researchStore *research.ResearchStore
researchFocus *research.FocusTracker
devMu sync.RWMutex
devTarget *url.URL
@ -107,6 +108,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/miniapp/dev/console", h.apiDevConsole)
mux.HandleFunc("/miniapp/dev/", h.serveDevProxy)
mux.HandleFunc("/miniapp/api/research", h.requireAuth(h.apiResearch))
mux.HandleFunc("/miniapp/api/research/focus", h.requireAuth(h.apiResearchFocus))
mux.HandleFunc("/miniapp/api/research/", h.requireAuth(h.apiResearchDetail))
}

View file

@ -8638,7 +8638,8 @@ Please report this to https://github.com/markedjs/marked.`, e) {
}
el.innerHTML = tasks.map(function(t) {
var sc = STATUS_COLORS[t.status] || STATUS_COLORS.pending;
return `<div class="card glass glass-interactive" style="cursor:pointer;padding:14px" onclick="openResearchTask('` + t.id + `')">` + '<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">' + '<span style="font-weight:600;font-size:15px">' + esc(t.title) + "</span>" + '<span style="font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px;background:' + sc.bg + ";color:" + sc.text + '">' + t.status + "</span>" + "</div>" + (t.description ? '<div style="color:var(--hint);font-size:13px;margin-top:4px;line-height:1.4">' + esc(t.description).substring(0, 120) + "</div>" : "") + '<div style="color:var(--hint);font-size:11px;margin-top:6px">' + t.document_count + " docs · " + new Date(t.created_at).toLocaleDateString() + "</div>" + "</div>";
var focusBadge = t.focused ? '<span style="font-size:10px;font-weight:600;padding:2px 6px;border-radius:8px;background:rgba(168,85,247,0.2);color:#a855f7">focused</span>' : "";
return '<div class="card glass glass-interactive" style="cursor:pointer;padding:14px' + (t.focused ? ";border-left:3px solid #a855f7" : "") + `" onclick="openResearchTask('` + t.id + `')">` + '<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">' + '<span style="font-weight:600;font-size:15px">' + esc(t.title) + "</span>" + '<div style="display:flex;gap:4px;align-items:center">' + focusBadge + '<span style="font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px;background:' + sc.bg + ";color:" + sc.text + '">' + t.status + "</span>" + "</div>" + "</div>" + (t.description ? '<div style="color:var(--hint);font-size:13px;margin-top:4px;line-height:1.4">' + esc(t.description).substring(0, 120) + "</div>" : "") + '<div style="color:var(--hint);font-size:11px;margin-top:6px">' + t.document_count + " docs · " + new Date(t.created_at).toLocaleDateString() + "</div>" + "</div>";
}).join("");
} catch (e) {
loading.classList.add("hidden");
@ -8667,14 +8668,17 @@ Please report this to https://github.com/markedjs/marked.`, e) {
html += '<div style="color:var(--hint);font-size:13px;line-height:1.5;margin-bottom:8px;white-space:pre-wrap">' + esc(task.description) + "</div>";
}
html += '<div style="color:var(--hint);font-size:11px">' + "Created: " + new Date(task.created_at).toLocaleString() + (task.completed_at ? " · Completed: " + new Date(task.completed_at).toLocaleString() : "") + "</div>";
if (canCancel || canReopen) {
html += '<div style="margin-top:10px;display:flex;gap:8px">';
if (task.focused) {
html += `<button class="worktree-btn dispose" onclick="researchSetFocus('` + id + `',false)" style="background:rgba(168,85,247,0.15);color:#a855f7;border-color:#a855f7">Forget</button>`;
} else {
html += `<button class="worktree-btn merge" onclick="researchSetFocus('` + id + `',true)" style="background:rgba(168,85,247,0.15);color:#a855f7;border-color:#a855f7">Recall</button>`;
}
if (canCancel)
html += `<button class="worktree-btn dispose" onclick="researchAction('` + id + `','cancel')">Cancel</button>`;
if (canReopen)
html += `<button class="worktree-btn merge" onclick="researchAction('` + id + `','reopen')">Reopen</button>`;
html += "</div>";
}
html += "</div>";
html += '<div class="card-title" style="margin-top:12px">Documents (' + task.documents.length + ")</div>";
if (task.documents.length === 0) {
@ -8741,11 +8745,22 @@ Please report this to https://github.com/markedjs/marked.`, e) {
loadResearch();
} catch (e) {}
}
async function researchSetFocus(taskId, recall) {
try {
await fetch(API_BASE + "/miniapp/api/research/focus?initData=" + encodeURIComponent(initData), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: recall ? "recall" : "forget", task_id: taskId })
});
openResearchTask(taskId);
} catch (e) {}
}
window.showNewTaskForm = showNewTaskForm;
window.hideNewTaskForm = hideNewTaskForm;
window.createResearchTask = createResearchTask;
window.openResearchTask = openResearchTask;
window.toggleResearchDoc = toggleResearchDoc;
window.researchAction = researchAction;
window.researchSetFocus = researchSetFocus;
window.showResearchList = showResearchList;
})();

65
pkg/research/focus.go Normal file
View file

@ -0,0 +1,65 @@
package research
import "sync"
// FocusTracker tracks which single research task is currently "recalled"
// into the agent's active context. At most one task can be focused at a time
// to avoid context pollution. Thread-safe.
type FocusTracker struct {
mu sync.RWMutex
taskID string // currently focused task ID (empty = none)
title string // currently focused task title
}
// NewFocusTracker creates a new FocusTracker.
func NewFocusTracker() *FocusTracker {
return &FocusTracker{}
}
// Focus sets the single focused task, replacing any previous focus.
func (ft *FocusTracker) Focus(taskID, title string) {
ft.mu.Lock()
ft.taskID = taskID
ft.title = title
ft.mu.Unlock()
}
// Unfocus removes a specific task from focus.
// Returns true if the task was focused.
func (ft *FocusTracker) Unfocus(taskID string) bool {
ft.mu.Lock()
defer ft.mu.Unlock()
if ft.taskID != taskID {
return false
}
ft.taskID = ""
ft.title = ""
return true
}
// UnfocusAll clears focus. Returns 1 if something was focused, 0 otherwise.
func (ft *FocusTracker) UnfocusAll() int {
ft.mu.Lock()
defer ft.mu.Unlock()
if ft.taskID == "" {
return 0
}
ft.taskID = ""
ft.title = ""
return 1
}
// Current returns the currently focused task ID and title.
// Returns empty strings if nothing is focused.
func (ft *FocusTracker) Current() (taskID, title string) {
ft.mu.RLock()
defer ft.mu.RUnlock()
return ft.taskID, ft.title
}
// IsFocused returns whether a specific task is currently focused.
func (ft *FocusTracker) IsFocused(taskID string) bool {
ft.mu.RLock()
defer ft.mu.RUnlock()
return ft.taskID == taskID
}

View file

@ -67,7 +67,69 @@ func OpenResearchStore(dbPath, workspace string) (*ResearchStore, error) {
_ = db.Close()
return nil, fmt.Errorf("create schema: %w", err)
}
return &ResearchStore{db: db, workspace: workspace}, nil
s := &ResearchStore{db: db, workspace: workspace}
if err := s.migrateSchema(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("migrate schema: %w", err)
}
return s, nil
}
// migrateSchema adds columns introduced after the initial schema.
func (s *ResearchStore) migrateSchema() error {
migrations := []struct {
table, column, ddl string
}{
{
"research_tasks", "interval",
`ALTER TABLE research_tasks ADD COLUMN interval TEXT NOT NULL DEFAULT '1d'`,
},
{
"research_tasks", "last_researched_at",
`ALTER TABLE research_tasks ADD COLUMN last_researched_at TEXT NOT NULL DEFAULT ''`,
},
}
for _, m := range migrations {
exists, err := s.columnExists(m.table, m.column)
if err != nil {
return err
}
if !exists {
if _, err := s.db.Exec(m.ddl); err != nil {
return fmt.Errorf("add column %s.%s: %w", m.table, m.column, err)
}
}
}
// Normalize legacy '24h' default to '1d'
if _, err := s.db.Exec(
`UPDATE research_tasks SET interval = '1d' WHERE interval = '24h'`,
); err != nil {
return fmt.Errorf("normalize interval 24h→1d: %w", err)
}
return nil
}
func (s *ResearchStore) columnExists(table, column string) (bool, error) {
rows, err := s.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var cid int
var name, typ string
var notNull int
var dfltValue sql.NullString
var pk int
if err := rows.Scan(&cid, &name, &typ, &notNull, &dfltValue, &pk); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
// Close closes the database connection.
@ -76,7 +138,15 @@ func (s *ResearchStore) Close() error {
}
// CreateTask creates a new research task with auto-generated slug and output directory.
func (s *ResearchStore) CreateTask(title, description string) (*Task, error) {
// interval is optional; pass "" to use DefaultResearchInterval.
func (s *ResearchStore) CreateTask(title, description, interval string) (*Task, error) {
if interval == "" {
interval = DefaultResearchInterval
}
if _, err := ParseInterval(interval); err != nil {
return nil, fmt.Errorf("invalid interval %q: %w", interval, err)
}
id := uuid.New().String()
slug := slugify(title)
now := time.Now().UTC()
@ -90,9 +160,9 @@ func (s *ResearchStore) CreateTask(title, description string) (*Task, error) {
nowStr := now.Format(time.RFC3339)
_, err := s.db.Exec(
`INSERT INTO research_tasks (id, title, slug, description, status, output_dir, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
id, title, slug, description, string(StatusPending), outputDir, nowStr, nowStr,
`INSERT INTO research_tasks (id, title, slug, description, status, output_dir, interval, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, title, slug, description, string(StatusPending), outputDir, interval, nowStr, nowStr,
)
if err != nil {
return nil, fmt.Errorf("insert task: %w", err)
@ -105,16 +175,18 @@ func (s *ResearchStore) CreateTask(title, description string) (*Task, error) {
Description: description,
Status: StatusPending,
OutputDir: outputDir,
Interval: interval,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
const taskColumns = `id, title, slug, description, status, output_dir, interval, last_researched_at, created_at, updated_at, completed_at`
// GetTask retrieves a single task by ID.
func (s *ResearchStore) GetTask(id string) (*Task, error) {
row := s.db.QueryRow(
`SELECT id, title, slug, description, status, output_dir, created_at, updated_at, completed_at
FROM research_tasks WHERE id = ?`, id)
`SELECT `+taskColumns+` FROM research_tasks WHERE id = ?`, id)
return scanTask(row)
}
@ -124,12 +196,10 @@ func (s *ResearchStore) ListTasks(status TaskStatus) ([]*Task, error) {
var err error
if status == "" {
rows, err = s.db.Query(
`SELECT id, title, slug, description, status, output_dir, created_at, updated_at, completed_at
FROM research_tasks ORDER BY created_at DESC`)
`SELECT ` + taskColumns + ` FROM research_tasks ORDER BY created_at DESC`)
} else {
rows, err = s.db.Query(
`SELECT id, title, slug, description, status, output_dir, created_at, updated_at, completed_at
FROM research_tasks WHERE status = ? ORDER BY created_at DESC`, string(status))
`SELECT `+taskColumns+` FROM research_tasks WHERE status = ? ORDER BY created_at DESC`, string(status))
}
if err != nil {
return nil, fmt.Errorf("list tasks: %w", err)
@ -147,6 +217,33 @@ func (s *ResearchStore) ListTasks(status TaskStatus) ([]*Task, error) {
return tasks, rows.Err()
}
// ListDueTasks returns active/pending tasks that are due for research (interval elapsed).
func (s *ResearchStore) ListDueTasks(maxTasks int) ([]*Task, error) {
rows, err := s.db.Query(
`SELECT ` + taskColumns + ` FROM research_tasks
WHERE status IN ('active', 'pending')
ORDER BY last_researched_at ASC, created_at ASC`)
if err != nil {
return nil, fmt.Errorf("list due tasks: %w", err)
}
defer rows.Close()
var due []*Task
for rows.Next() {
t, err := scanTaskRow(rows)
if err != nil {
return nil, err
}
if t.IsDue() {
due = append(due, t)
if len(due) >= maxTasks {
break
}
}
}
return due, rows.Err()
}
// SetTaskStatus updates task status with transition validation.
func (s *ResearchStore) SetTaskStatus(id string, status TaskStatus) error {
task, err := s.GetTask(id)
@ -251,17 +348,65 @@ func (s *ResearchStore) DocumentCount(taskID string) (int, error) {
return count, err
}
// SearchTasks searches tasks by title or slug partial match (case-insensitive).
func (s *ResearchStore) SearchTasks(query string) ([]*Task, error) {
like := "%" + query + "%"
rows, err := s.db.Query(
`SELECT `+taskColumns+` FROM research_tasks
WHERE title LIKE ? COLLATE NOCASE OR slug LIKE ? COLLATE NOCASE
ORDER BY updated_at DESC`, like, like)
if err != nil {
return nil, fmt.Errorf("search tasks: %w", err)
}
defer rows.Close()
var tasks []*Task
for rows.Next() {
t, err := scanTaskRow(rows)
if err != nil {
return nil, err
}
tasks = append(tasks, t)
}
return tasks, rows.Err()
}
// TouchLastResearched updates the last_researched_at timestamp for a task.
func (s *ResearchStore) TouchLastResearched(taskID string) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
`UPDATE research_tasks SET last_researched_at = ?, updated_at = ? WHERE id = ?`,
now, now, taskID)
return err
}
// SetInterval updates the research interval for a task.
func (s *ResearchStore) SetInterval(taskID, interval string) error {
if _, err := ParseInterval(interval); err != nil {
return fmt.Errorf("invalid interval %q: %w", interval, err)
}
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
`UPDATE research_tasks SET interval = ?, updated_at = ? WHERE id = ?`,
interval, now, taskID)
return err
}
// --- helpers ---
func scanTask(row *sql.Row) (*Task, error) {
var t Task
var statusStr, createdStr, updatedStr, completedStr string
var statusStr, lastResearchedStr, createdStr, updatedStr, completedStr string
err := row.Scan(&t.ID, &t.Title, &t.Slug, &t.Description, &statusStr,
&t.OutputDir, &createdStr, &updatedStr, &completedStr)
&t.OutputDir, &t.Interval, &lastResearchedStr, &createdStr, &updatedStr, &completedStr)
if err != nil {
return nil, err
}
t.Status = TaskStatus(statusStr)
if t.Interval == "" {
t.Interval = DefaultResearchInterval
}
t.LastResearchedAt, _ = time.Parse(time.RFC3339, lastResearchedStr)
t.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
if completedStr != "" {
@ -276,13 +421,17 @@ type rowScanner interface {
func scanTaskRow(row rowScanner) (*Task, error) {
var t Task
var statusStr, createdStr, updatedStr, completedStr string
var statusStr, lastResearchedStr, createdStr, updatedStr, completedStr string
err := row.Scan(&t.ID, &t.Title, &t.Slug, &t.Description, &statusStr,
&t.OutputDir, &createdStr, &updatedStr, &completedStr)
&t.OutputDir, &t.Interval, &lastResearchedStr, &createdStr, &updatedStr, &completedStr)
if err != nil {
return nil, err
}
t.Status = TaskStatus(statusStr)
if t.Interval == "" {
t.Interval = DefaultResearchInterval
}
t.LastResearchedAt, _ = time.Parse(time.RFC3339, lastResearchedStr)
t.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
if completedStr != "" {

View file

@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"testing"
"time"
)
func setupTestStore(t *testing.T) (*ResearchStore, string) {
@ -21,7 +22,7 @@ func setupTestStore(t *testing.T) (*ResearchStore, string) {
func TestCreateAndGetTask(t *testing.T) {
store, dir := setupTestStore(t)
task, err := store.CreateTask("Test Research", "A test description")
task, err := store.CreateTask("Test Research", "A test description", "")
if err != nil {
t.Fatalf("create task: %v", err)
}
@ -50,8 +51,8 @@ func TestCreateAndGetTask(t *testing.T) {
func TestListTasks(t *testing.T) {
store, _ := setupTestStore(t)
store.CreateTask("Task A", "")
store.CreateTask("Task B", "")
store.CreateTask("Task A", "", "")
store.CreateTask("Task B", "", "")
all, err := store.ListTasks("")
if err != nil {
@ -81,7 +82,7 @@ func TestListTasks(t *testing.T) {
func TestSetTaskStatus(t *testing.T) {
store, _ := setupTestStore(t)
task, _ := store.CreateTask("Status Test", "")
task, _ := store.CreateTask("Status Test", "", "")
// Valid: pending → active
if err := store.SetTaskStatus(task.ID, StatusActive); err != nil {
@ -113,7 +114,7 @@ func TestSetTaskStatus(t *testing.T) {
func TestAddAndListDocuments(t *testing.T) {
store, dir := setupTestStore(t)
task, _ := store.CreateTask("Doc Test", "")
task, _ := store.CreateTask("Doc Test", "", "")
// Create a test file
filePath := filepath.Join(dir, task.OutputDir, "001-finding.md")
@ -144,7 +145,7 @@ func TestAddAndListDocuments(t *testing.T) {
func TestDocumentCount(t *testing.T) {
store, _ := setupTestStore(t)
task, _ := store.CreateTask("Count Test", "")
task, _ := store.CreateTask("Count Test", "", "")
count, _ := store.DocumentCount(task.ID)
if count != 0 {
@ -163,7 +164,7 @@ func TestDocumentCount(t *testing.T) {
func TestDeleteTask(t *testing.T) {
store, _ := setupTestStore(t)
task, _ := store.CreateTask("Delete Test", "")
task, _ := store.CreateTask("Delete Test", "", "")
store.AddDocument(task.ID, "D1", "p1", "finding", "")
if err := store.DeleteTask(task.ID); err != nil {
@ -180,7 +181,7 @@ func TestDeleteTask(t *testing.T) {
func TestUpdateTask(t *testing.T) {
store, _ := setupTestStore(t)
task, _ := store.CreateTask("Original", "desc")
task, _ := store.CreateTask("Original", "desc", "")
if err := store.UpdateTask(task.ID, "Updated", "new desc"); err != nil {
t.Fatalf("update: %v", err)
}
@ -191,6 +192,106 @@ func TestUpdateTask(t *testing.T) {
}
}
func TestParseInterval(t *testing.T) {
cases := []struct {
input string
want string // duration string
err bool
}{
{"1d", "24h0m0s", false},
{"7d", "168h0m0s", false},
{"6h", "6h0m0s", false},
{"30m", "30m0s", false},
{"", "", true},
{"abc", "", true},
}
for _, tc := range cases {
got, err := ParseInterval(tc.input)
if tc.err {
if err == nil {
t.Errorf("ParseInterval(%q) expected error", tc.input)
}
continue
}
if err != nil {
t.Errorf("ParseInterval(%q) error: %v", tc.input, err)
continue
}
if got.String() != tc.want {
t.Errorf("ParseInterval(%q) = %v, want %v", tc.input, got, tc.want)
}
}
}
func TestTaskIsDue(t *testing.T) {
// Zero LastResearchedAt → always due
task := &Task{Interval: "1d"}
if !task.IsDue() {
t.Error("zero LastResearchedAt should be due")
}
// Recently researched → not due
task.LastResearchedAt = time.Now().Add(-1 * time.Hour)
task.Interval = "1d"
if task.IsDue() {
t.Error("researched 1h ago with 1d interval should not be due")
}
// Long ago → due
task.LastResearchedAt = time.Now().Add(-25 * time.Hour)
if !task.IsDue() {
t.Error("researched 25h ago with 1d interval should be due")
}
}
func TestListDueTasks(t *testing.T) {
store, _ := setupTestStore(t)
// Create tasks with different intervals
t1, _ := store.CreateTask("Fast", "", "1h")
t2, _ := store.CreateTask("Slow", "", "7d")
// Both pending with no LastResearchedAt → both due
due, err := store.ListDueTasks(10)
if err != nil {
t.Fatalf("list due: %v", err)
}
if len(due) != 2 {
t.Errorf("expected 2 due tasks, got %d", len(due))
}
// Touch t1 → it should no longer be due (1h not elapsed)
store.TouchLastResearched(t1.ID)
due, _ = store.ListDueTasks(10)
if len(due) != 1 {
t.Errorf("expected 1 due task after touch, got %d", len(due))
}
if len(due) > 0 && due[0].ID != t2.ID {
t.Errorf("expected Slow task to be due, got %s", due[0].Title)
}
}
func TestSetInterval(t *testing.T) {
store, _ := setupTestStore(t)
task, _ := store.CreateTask("Interval Test", "", "")
if task.Interval != DefaultResearchInterval {
t.Errorf("default interval = %q, want %q", task.Interval, DefaultResearchInterval)
}
if err := store.SetInterval(task.ID, "6h"); err != nil {
t.Fatalf("set interval: %v", err)
}
got, _ := store.GetTask(task.ID)
if got.Interval != "6h" {
t.Errorf("interval = %q, want 6h", got.Interval)
}
// Invalid interval
if err := store.SetInterval(task.ID, "xyz"); err == nil {
t.Error("expected error for invalid interval")
}
}
func TestCanTransition(t *testing.T) {
cases := []struct {
from, to TaskStatus

View file

@ -1,6 +1,11 @@
package research
import "time"
import (
"fmt"
"strconv"
"strings"
"time"
)
// TaskStatus represents the lifecycle state of a research task.
type TaskStatus string
@ -13,6 +18,16 @@ const (
StatusCanceled TaskStatus = "canceled"
)
// MinFindingsForCompletion is the minimum number of findings required
// before a heartbeat execution can mark a research task as completed.
const MinFindingsForCompletion = 5
// DefaultResearchInterval is the default research interval for new tasks.
const DefaultResearchInterval = "1d"
// DefaultHeartbeatSearchQuota is the max web searches per heartbeat execution.
const DefaultHeartbeatSearchQuota = 3
// Task represents a research task tracked in the database.
type Task struct {
ID string `json:"id"`
@ -21,11 +36,44 @@ type Task struct {
Description string `json:"description"`
Status TaskStatus `json:"status"`
OutputDir string `json:"output_dir"`
Interval string `json:"interval"`
LastResearchedAt time.Time `json:"last_researched_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CompletedAt time.Time `json:"completed_at,omitempty"`
}
// IsDue returns true if enough time has elapsed since the last research.
func (t *Task) IsDue() bool {
if t.LastResearchedAt.IsZero() {
return true
}
interval, err := ParseInterval(t.Interval)
if err != nil || interval <= 0 {
interval = 24 * time.Hour
}
return time.Since(t.LastResearchedAt) >= interval
}
// ParseInterval parses a duration string with support for "d" (days) suffix.
// Examples: "30m", "6h", "1d", "7d", "24h".
func ParseInterval(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("empty interval")
}
// Handle "d" suffix: e.g. "1d" -> "24h", "7d" -> "168h"
if strings.HasSuffix(s, "d") {
numStr := strings.TrimSuffix(s, "d")
days, err := strconv.Atoi(numStr)
if err != nil {
return 0, fmt.Errorf("invalid day interval %q: %w", s, err)
}
return time.Duration(days) * 24 * time.Hour, nil
}
return time.ParseDuration(s)
}
// Document represents a research output document linked to a task.
type Document struct {
ID string `json:"id"`

View file

@ -0,0 +1,59 @@
package tools
import (
"context"
"sync/atomic"
)
type heartbeatKey struct{}
// WithHeartbeatContext marks the context as a heartbeat execution.
func WithHeartbeatContext(ctx context.Context) context.Context {
return context.WithValue(ctx, heartbeatKey{}, true)
}
// IsHeartbeatContext returns true if the context was created by a heartbeat execution.
func IsHeartbeatContext(ctx context.Context) bool {
v, _ := ctx.Value(heartbeatKey{}).(bool)
return v
}
// WebSearchQuota tracks remaining web searches for a heartbeat execution.
type WebSearchQuota struct {
max int32
remaining atomic.Int32
}
// TryConsume atomically decrements the quota. Returns false if exhausted.
func (q *WebSearchQuota) TryConsume() bool {
for {
cur := q.remaining.Load()
if cur <= 0 {
return false
}
if q.remaining.CompareAndSwap(cur, cur-1) {
return true
}
}
}
// Max returns the initial quota limit.
func (q *WebSearchQuota) Max() int32 { return q.max }
// Remaining returns the current remaining quota.
func (q *WebSearchQuota) Remaining() int32 { return q.remaining.Load() }
type searchQuotaKey struct{}
// WithWebSearchQuota attaches a web search quota to the context.
func WithWebSearchQuota(ctx context.Context, quota int) context.Context {
q := &WebSearchQuota{max: int32(quota)}
q.remaining.Store(int32(quota))
return context.WithValue(ctx, searchQuotaKey{}, q)
}
// GetWebSearchQuota returns the search quota from the context, or nil if not set.
func GetWebSearchQuota(ctx context.Context) *WebSearchQuota {
q, _ := ctx.Value(searchQuotaKey{}).(*WebSearchQuota)
return q
}

View file

@ -0,0 +1,82 @@
package tools
import (
"context"
"sync"
"testing"
)
func TestWebSearchQuotaBasic(t *testing.T) {
ctx := context.Background()
// No quota → nil
if q := GetWebSearchQuota(ctx); q != nil {
t.Error("expected nil quota on bare context")
}
ctx = WithWebSearchQuota(ctx, 3)
q := GetWebSearchQuota(ctx)
if q == nil {
t.Fatal("expected non-nil quota")
}
if q.Max() != 3 {
t.Errorf("max = %d, want 3", q.Max())
}
if q.Remaining() != 3 {
t.Errorf("remaining = %d, want 3", q.Remaining())
}
// Consume 3
for i := range 3 {
if !q.TryConsume() {
t.Errorf("consume %d should succeed", i+1)
}
}
// 4th should fail
if q.TryConsume() {
t.Error("consume after exhaustion should fail")
}
if q.Remaining() != 0 {
t.Errorf("remaining = %d, want 0", q.Remaining())
}
}
func TestWebSearchQuotaConcurrent(t *testing.T) {
ctx := WithWebSearchQuota(context.Background(), 100)
q := GetWebSearchQuota(ctx)
var wg sync.WaitGroup
consumed := make(chan bool, 200)
for range 200 {
wg.Add(1)
go func() {
defer wg.Done()
consumed <- q.TryConsume()
}()
}
wg.Wait()
close(consumed)
ok := 0
for c := range consumed {
if c {
ok++
}
}
if ok != 100 {
t.Errorf("consumed = %d, want exactly 100", ok)
}
}
func TestHeartbeatContext(t *testing.T) {
ctx := context.Background()
if IsHeartbeatContext(ctx) {
t.Error("bare context should not be heartbeat")
}
ctx = WithHeartbeatContext(ctx)
if !IsHeartbeatContext(ctx) {
t.Error("should be heartbeat after WithHeartbeatContext")
}
}

View file

@ -12,20 +12,27 @@ import (
)
// ResearchTool provides research task management for the agent.
// It implements StatusProvider to inject a lightweight research catalog
// and focus state into the system prompt.
type ResearchTool struct {
store *research.ResearchStore
workspace string
focus *research.FocusTracker
}
// NewResearchTool creates a new ResearchTool.
func NewResearchTool(store *research.ResearchStore, workspace string) *ResearchTool {
return &ResearchTool{store: store, workspace: workspace}
func NewResearchTool(store *research.ResearchStore, workspace string, focus *research.FocusTracker) *ResearchTool {
return &ResearchTool{
store: store,
workspace: workspace,
focus: focus,
}
}
func (t *ResearchTool) Name() string { return "research" }
func (t *ResearchTool) Description() string {
return "Manage research tasks and findings. Use list_tasks to discover pending research, set_status to update task state, add_finding to record research results as markdown documents, and get_task to view task details."
return "Manage research tasks and findings. Actions: list_tasks, get_task, set_status, set_interval, add_finding, recall (load findings into context), forget (release focus when changing topics)."
}
func (t *ResearchTool) Parameters() map[string]any {
@ -34,12 +41,28 @@ func (t *ResearchTool) Parameters() map[string]any {
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []string{"list_tasks", "get_task", "set_status", "add_finding"},
"enum": []string{
"list_tasks",
"get_task",
"set_status",
"set_interval",
"add_finding",
"recall",
"forget",
},
"description": "Action to perform.",
},
"interval": map[string]any{
"type": "string",
"description": "Research interval (for set_interval). Examples: '6h', '1d', '7d'.",
},
"query": map[string]any{
"type": "string",
"description": "Search query to find tasks by title/slug (for recall/forget). Alternative to task_id.",
},
"task_id": map[string]any{
"type": "string",
"description": "Task ID (required for get_task, set_status, add_finding).",
"description": "Task ID (required for get_task, set_status, add_finding; optional for recall/forget).",
},
"status_filter": map[string]any{
"type": "string",
@ -76,14 +99,50 @@ func (t *ResearchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
case "get_task":
return t.getTask(args)
case "set_status":
return t.setStatus(args)
return t.setStatus(ctx, args)
case "set_interval":
return t.setInterval(args)
case "add_finding":
return t.addFinding(args)
case "recall":
return t.recall(args)
case "forget":
return t.forget(args)
// Keep backward compat for load_context
case "load_context":
return t.recall(args)
default:
return ErrorResult("unknown action: " + action)
}
}
// RuntimeStatus implements StatusProvider. It injects a lightweight research
// catalog (titles only) and current focus state into the system prompt.
func (t *ResearchTool) RuntimeStatus() string {
tasks, err := t.store.ListTasks("")
if err != nil || len(tasks) == 0 {
return ""
}
var b strings.Builder
b.WriteString("# Research Knowledge Base\n\n")
b.WriteString("Available research topics:\n")
for _, task := range tasks {
docCount, _ := t.store.DocumentCount(task.ID)
b.WriteString(fmt.Sprintf("- \"%s\" [%s, %d docs] (id: %s)\n",
task.Title, task.Status, docCount, task.ID))
}
if focusID, focusTitle := t.focus.Current(); focusID != "" {
b.WriteString(fmt.Sprintf("\nCurrently focused: \"%s\" (id: %s)\n", focusTitle, focusID))
}
b.WriteString("\nUse `research recall` to load findings when the user asks about a research topic.\n")
b.WriteString("Use `research forget` to release focus when changing topics.\n")
return b.String()
}
func (t *ResearchTool) listTasks(args map[string]any) *ToolResult {
filter, _ := args["status_filter"].(string)
tasks, err := t.store.ListTasks(research.TaskStatus(filter))
@ -140,19 +199,46 @@ func (t *ResearchTool) getTask(args map[string]any) *ToolResult {
return NewToolResult(b.String())
}
func (t *ResearchTool) setStatus(args map[string]any) *ToolResult {
func (t *ResearchTool) setStatus(ctx context.Context, args map[string]any) *ToolResult {
taskID, _ := args["task_id"].(string)
status, _ := args["status"].(string)
if taskID == "" || status == "" {
return ErrorResult("task_id and status are required")
}
// Guard: during heartbeat, prevent completing tasks with too few findings
if IsHeartbeatContext(ctx) && research.TaskStatus(status) == research.StatusCompleted {
docCount, err := t.store.DocumentCount(taskID)
if err != nil {
return ErrorResult(fmt.Sprintf("check document count: %v", err))
}
if docCount < research.MinFindingsForCompletion {
return ErrorResult(fmt.Sprintf(
"cannot mark as completed during heartbeat: task has %d findings (minimum %d required). Add more findings first.",
docCount,
research.MinFindingsForCompletion,
))
}
}
if err := t.store.SetTaskStatus(taskID, research.TaskStatus(status)); err != nil {
return ErrorResult(fmt.Sprintf("set status: %v", err))
}
return NewToolResult(fmt.Sprintf("Task status updated to %s.", status))
}
func (t *ResearchTool) setInterval(args map[string]any) *ToolResult {
taskID, _ := args["task_id"].(string)
interval, _ := args["interval"].(string)
if taskID == "" || interval == "" {
return ErrorResult("task_id and interval are required")
}
if err := t.store.SetInterval(taskID, interval); err != nil {
return ErrorResult(fmt.Sprintf("set interval: %v", err))
}
return NewToolResult(fmt.Sprintf("Research interval updated to %s.", interval))
}
var sanitizeRe = regexp.MustCompile(`[^a-zA-Z0-9_-]+`)
func (t *ResearchTool) addFinding(args map[string]any) *ToolResult {
@ -199,8 +285,133 @@ func (t *ResearchTool) addFinding(args map[string]any) *ToolResult {
return ErrorResult(fmt.Sprintf("add document: %v", err))
}
// Touch last_researched_at for interval tracking
_ = t.store.TouchLastResearched(taskID)
return NewToolResult(fmt.Sprintf(
"Finding recorded:\n- File: %s\n- Document ID: %s\n- Seq: %d",
relPath, doc.ID, doc.Seq,
))
}
// maxContextBytes caps the total size of loaded research context.
const maxContextBytes = 100 * 1024 // 100KB
// recall loads all findings for a task and marks it as focused in the system prompt.
func (t *ResearchTool) recall(args map[string]any) *ToolResult {
task, err := t.resolveTask(args)
if err != nil {
return ErrorResult(err.Error())
}
docs, err := t.store.ListDocuments(task.ID)
if err != nil {
return ErrorResult(fmt.Sprintf("list documents: %v", err))
}
// Mark as focused
t.focus.Focus(task.ID, task.Title)
var b strings.Builder
b.WriteString(fmt.Sprintf("# Research Context: %s\n\n", task.Title))
b.WriteString(fmt.Sprintf("**Status**: %s | **Documents**: %d\n", task.Status, len(docs)))
if task.Description != "" {
b.WriteString(fmt.Sprintf("**Description**: %s\n", task.Description))
}
b.WriteString("\n---\n\n")
if len(docs) == 0 {
b.WriteString("No findings recorded yet.\n")
return NewToolResult(b.String())
}
totalBytes := b.Len()
loaded := 0
for _, d := range docs {
absPath := filepath.Join(t.workspace, d.FilePath)
data, readErr := os.ReadFile(absPath)
if readErr != nil {
b.WriteString(fmt.Sprintf("## [%d] %s\n\n(file not found: %s)\n\n", d.Seq, d.Title, d.FilePath))
loaded++
continue
}
content := string(data)
entrySize := len(d.Title) + len(content) + 40 // rough overhead for headers
if totalBytes+entrySize > maxContextBytes {
remaining := len(docs) - loaded
b.WriteString(
fmt.Sprintf(
"\n---\n*Context limit reached. %d more finding(s) not shown. Use get_task to see the full list.*\n",
remaining,
),
)
break
}
b.WriteString(fmt.Sprintf("## [%d] %s\n\n", d.Seq, d.Title))
b.WriteString(content)
if !strings.HasSuffix(content, "\n") {
b.WriteByte('\n')
}
b.WriteString("\n---\n\n")
totalBytes += entrySize
loaded++
}
return NewToolResult(b.String())
}
// forget removes a task from the focus set.
func (t *ResearchTool) forget(args map[string]any) *ToolResult {
taskID, _ := args["task_id"].(string)
query, _ := args["query"].(string)
// If no args, forget all
if taskID == "" && query == "" {
count := t.focus.UnfocusAll()
if count == 0 {
return NewToolResult("No research topics were focused.")
}
return NewToolResult(fmt.Sprintf("Released focus on %d research topic(s).", count))
}
task, err := t.resolveTask(args)
if err != nil {
return ErrorResult(err.Error())
}
if !t.focus.Unfocus(task.ID) {
return NewToolResult(fmt.Sprintf("Research topic \"%s\" was not focused.", task.Title))
}
return NewToolResult(
fmt.Sprintf("Released focus on \"%s\". Research findings are no longer in active context.", task.Title),
)
}
// resolveTask finds a task by task_id or query.
func (t *ResearchTool) resolveTask(args map[string]any) (*research.Task, error) {
taskID, _ := args["task_id"].(string)
query, _ := args["query"].(string)
if taskID == "" && query == "" {
return nil, fmt.Errorf("task_id or query is required")
}
if taskID != "" {
task, err := t.store.GetTask(taskID)
if err != nil {
return nil, fmt.Errorf("get task: %w", err)
}
return task, nil
}
tasks, err := t.store.SearchTasks(query)
if err != nil {
return nil, fmt.Errorf("search tasks: %w", err)
}
if len(tasks) == 0 {
return nil, fmt.Errorf("no research task found matching %q", query)
}
return tasks[0], nil
}

View file

@ -834,6 +834,15 @@ func (t *WebSearchTool) Parameters() map[string]any {
}
func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
// Enforce heartbeat web search quota
if quota := GetWebSearchQuota(ctx); quota != nil {
if !quota.TryConsume() {
return ErrorResult(fmt.Sprintf(
"web_search quota exhausted (%d/%d used this heartbeat). Work with findings you already have.",
quota.Max(), quota.Max()))
}
}
query, ok := args["query"].(string)
if !ok {
return ErrorResult("query is required")

View file

@ -35,6 +35,8 @@ type researchTaskJSON struct {
Description string `json:"description"`
Status string `json:"status"`
OutputDir string `json:"output_dir"`
Interval string `json:"interval"`
LastResearchedAt string `json:"last_researched_at,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
CompletedAt string `json:"completed_at,omitempty"`
@ -60,10 +62,14 @@ func taskToJSON(t *research.Task, docCount int) researchTaskJSON {
Description: t.Description,
Status: string(t.Status),
OutputDir: t.OutputDir,
Interval: t.Interval,
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z"),
DocumentCount: docCount,
}
if !t.LastResearchedAt.IsZero() {
r.LastResearchedAt = t.LastResearchedAt.Format("2006-01-02T15:04:05Z")
}
if !t.CompletedAt.IsZero() {
r.CompletedAt = t.CompletedAt.Format("2006-01-02T15:04:05Z")
}
@ -103,6 +109,7 @@ func (h *Handler) handleResearch(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
Description string `json:"description"`
Interval string `json:"interval"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
@ -112,7 +119,11 @@ func (h *Handler) handleResearch(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"title is required"}`, http.StatusBadRequest)
return
}
task, err := store.CreateTask(strings.TrimSpace(req.Title), strings.TrimSpace(req.Description))
task, err := store.CreateTask(
strings.TrimSpace(req.Title),
strings.TrimSpace(req.Description),
strings.TrimSpace(req.Interval),
)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
return

View file

@ -5,6 +5,8 @@ export interface ResearchTask {
description: string
status: "pending" | "active" | "completed" | "failed" | "canceled"
output_dir: string
interval: string
last_researched_at?: string
created_at: string
updated_at: string
completed_at?: string
@ -68,18 +70,19 @@ export async function getResearchTask(
export async function createResearchTask(
title: string,
description: string,
interval?: string,
): Promise<ResearchTask> {
return request<ResearchTask>("/api/research", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, description }),
body: JSON.stringify({ title, description, interval }),
})
}
export async function researchTaskAction(
id: string,
action: "cancel" | "reopen" | "update",
data?: { title?: string; description?: string },
action: "cancel" | "reopen" | "update" | "set_interval",
data?: { title?: string; description?: string; interval?: string },
): Promise<ResearchTaskDetail> {
return request<ResearchTaskDetail>(
`/api/research/${encodeURIComponent(id)}`,

View file

@ -218,9 +218,13 @@ function TaskCard({ task }: { task: ResearchTask }) {
count: task.document_count,
})}
</span>
<span> {task.interval || "24h"}</span>
{task.last_researched_at ? (
<span>
{new Date(task.created_at).toLocaleDateString()}
{t("pages.research.last_researched")}:{" "}
{new Date(task.last_researched_at).toLocaleDateString()}
</span>
) : null}
</div>
</CardContent>
</Card>

View file

@ -171,6 +171,13 @@ export function TaskDetailPage({ taskId }: { taskId: string }) {
</div>
<div className="text-muted-foreground space-y-1 text-xs">
<div>
{t("pages.research.interval")}: {task.interval || "24h"}
{task.last_researched_at ? (
<> · {t("pages.research.last_researched")}:{" "}
{new Date(task.last_researched_at).toLocaleString()}</>
) : null}
</div>
<div>
{t("pages.research.created_at")}:{" "}
{new Date(task.created_at).toLocaleString()}

View file

@ -474,6 +474,8 @@
"documents_title": "Documents ({{count}})",
"documents_count": "{{count}} docs",
"no_documents": "No documents yet.",
"interval": "Interval",
"last_researched": "Last researched",
"created_at": "Created",
"completed_at": "Completed",
"output_dir": "Output",

View file

@ -474,6 +474,8 @@
"documents_title": "文档 ({{count}})",
"documents_count": "{{count}} 篇文档",
"no_documents": "暂无文档。",
"interval": "调查频率",
"last_researched": "最后调查",
"created_at": "创建时间",
"completed_at": "完成时间",
"output_dir": "输出目录",