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:
parent
464a614af2
commit
876e7e8911
21 changed files with 1142 additions and 91 deletions
|
|
@ -63,6 +63,8 @@ const (
|
||||||
type gatewayServices struct {
|
type gatewayServices struct {
|
||||||
CronService *cron.CronService
|
CronService *cron.CronService
|
||||||
HeartbeatService *heartbeat.HeartbeatService
|
HeartbeatService *heartbeat.HeartbeatService
|
||||||
|
ResearchStore *research.ResearchStore
|
||||||
|
ResearchFocus *research.FocusTracker
|
||||||
MediaStore media.MediaStore
|
MediaStore media.MediaStore
|
||||||
ChannelManager *channels.Manager
|
ChannelManager *channels.Manager
|
||||||
DeviceService *devices.Service
|
DeviceService *devices.Service
|
||||||
|
|
@ -340,8 +342,13 @@ func setupAndStartServices(
|
||||||
if rsErr != nil {
|
if rsErr != nil {
|
||||||
logger.ErrorCF("research", "Failed to open research store", map[string]any{"error": rsErr.Error()})
|
logger.ErrorCF("research", "Failed to open research store", map[string]any{"error": rsErr.Error()})
|
||||||
} else {
|
} else {
|
||||||
agentLoop.RegisterTool(tools.NewResearchTool(researchStore, cfg.WorkspacePath()))
|
focusTracker := research.NewFocusTracker()
|
||||||
|
agentLoop.RegisterTool(tools.NewResearchTool(researchStore, cfg.WorkspacePath(), focusTracker))
|
||||||
handler.SetResearchStore(researchStore)
|
handler.SetResearchStore(researchStore)
|
||||||
|
handler.SetResearchFocus(focusTracker)
|
||||||
|
services.ResearchStore = researchStore
|
||||||
|
services.ResearchFocus = focusTracker
|
||||||
|
services.HeartbeatService.SetResearchStore(researchStore)
|
||||||
fmt.Println("✓ Research store initialized")
|
fmt.Println("✓ Research store initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -547,6 +554,9 @@ func restartServices(
|
||||||
cfg.Heartbeat.Enabled,
|
cfg.Heartbeat.Enabled,
|
||||||
)
|
)
|
||||||
services.HeartbeatService.SetBus(msgBus)
|
services.HeartbeatService.SetBus(msgBus)
|
||||||
|
if services.ResearchStore != nil {
|
||||||
|
services.HeartbeatService.SetResearchStore(services.ResearchStore)
|
||||||
|
}
|
||||||
services.HeartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
services.HeartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||||
if channel == "" || chatID == "" {
|
if channel == "" || chatID == "" {
|
||||||
channel, chatID = "cli", "direct"
|
channel, chatID = "cli", "direct"
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/media"
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/research"
|
||||||
"github.com/sipeed/picoclaw/pkg/routing"
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
|
|
@ -911,6 +912,9 @@ func (al *AgentLoop) ProcessDirectWithChannel(
|
||||||
// ProcessHeartbeat processes a heartbeat request without session history.
|
// ProcessHeartbeat processes a heartbeat request without session history.
|
||||||
// Each heartbeat is independent and doesn't accumulate context.
|
// Each heartbeat is independent and doesn't accumulate context.
|
||||||
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) {
|
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()
|
agent := al.GetRegistry().GetDefaultAgent()
|
||||||
if agent == nil {
|
if agent == nil {
|
||||||
return "", fmt.Errorf("no default agent for heartbeat")
|
return "", fmt.Errorf("no default agent for heartbeat")
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/constants"
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/research"
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
@ -39,6 +40,7 @@ type HeartbeatService struct {
|
||||||
bus *bus.MessageBus
|
bus *bus.MessageBus
|
||||||
state *state.Manager
|
state *state.Manager
|
||||||
handler HeartbeatHandler
|
handler HeartbeatHandler
|
||||||
|
researchStore *research.ResearchStore
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
enabled bool
|
enabled bool
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
@ -87,6 +89,13 @@ func (hs *HeartbeatService) SetHeartbeatThreadID(threadID int) {
|
||||||
hs.heartbeatThreadID = threadID
|
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
|
// ResetSuppression clears the notification suppression so the next
|
||||||
// non-silent heartbeat result will be delivered to the user again.
|
// non-silent heartbeat result will be delivered to the user again.
|
||||||
// Typically called when a user message arrives.
|
// 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")
|
now := time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
researchCtx := hs.buildResearchContext()
|
||||||
return fmt.Sprintf(`# Heartbeat Check
|
return fmt.Sprintf(`# Heartbeat Check
|
||||||
|
|
||||||
Current time: %s
|
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
|
If there is nothing that requires attention, respond ONLY with: HEARTBEAT_OK
|
||||||
|
|
||||||
%s
|
%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
|
// createDefaultHeartbeatTemplate creates the default HEARTBEAT.md file
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ func (h *Handler) SetResearchStore(rs *research.ResearchStore) {
|
||||||
h.researchStore = rs
|
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).
|
// apiResearch handles GET /miniapp/api/research (list) and POST (create).
|
||||||
func (h *Handler) apiResearch(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiResearch(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.researchStore == nil {
|
if h.researchStore == nil {
|
||||||
|
|
@ -40,10 +45,13 @@ type researchTaskResponse struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
OutputDir string `json:"output_dir"`
|
OutputDir string `json:"output_dir"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
LastResearchedAt string `json:"last_researched_at,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
CompletedAt string `json:"completed_at,omitempty"`
|
CompletedAt string `json:"completed_at,omitempty"`
|
||||||
DocumentCount int `json:"document_count"`
|
DocumentCount int `json:"document_count"`
|
||||||
|
Focused bool `json:"focused"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func taskToResponse(t *research.Task, docCount int) researchTaskResponse {
|
func taskToResponse(t *research.Task, docCount int) researchTaskResponse {
|
||||||
|
|
@ -54,10 +62,14 @@ func taskToResponse(t *research.Task, docCount int) researchTaskResponse {
|
||||||
Description: t.Description,
|
Description: t.Description,
|
||||||
Status: string(t.Status),
|
Status: string(t.Status),
|
||||||
OutputDir: t.OutputDir,
|
OutputDir: t.OutputDir,
|
||||||
|
Interval: t.Interval,
|
||||||
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
DocumentCount: docCount,
|
DocumentCount: docCount,
|
||||||
}
|
}
|
||||||
|
if !t.LastResearchedAt.IsZero() {
|
||||||
|
resp.LastResearchedAt = t.LastResearchedAt.Format("2006-01-02T15:04:05Z")
|
||||||
|
}
|
||||||
if !t.CompletedAt.IsZero() {
|
if !t.CompletedAt.IsZero() {
|
||||||
resp.CompletedAt = t.CompletedAt.Format("2006-01-02T15:04:05Z")
|
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))
|
result := make([]researchTaskResponse, 0, len(tasks))
|
||||||
for _, t := range tasks {
|
for _, t := range tasks {
|
||||||
docCount, _ := h.researchStore.DocumentCount(t.ID)
|
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)
|
writeJSON(w, result)
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +100,7 @@ func (h *Handler) apiResearchCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
||||||
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
|
||||||
|
|
@ -94,7 +111,11 @@ func (h *Handler) apiResearchCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
||||||
return
|
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{
|
writeJSON(w, researchTaskDetailResponse{
|
||||||
researchTaskResponse: taskToResponse(task, len(docs)),
|
researchTaskResponse: resp,
|
||||||
Documents: docResponses,
|
Documents: docResponses,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -191,6 +217,7 @@ func (h *Handler) apiResearchTaskAction(w http.ResponseWriter, r *http.Request,
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
||||||
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
|
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)
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
|
||||||
return
|
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":
|
case "update":
|
||||||
task, err := h.researchStore.GetTask(taskID)
|
task, err := h.researchStore.GetTask(taskID)
|
||||||
if err != nil {
|
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)
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
||||||
return
|
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:
|
default:
|
||||||
http.Error(w, `{"error":"unknown action"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"unknown action"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -273,3 +319,65 @@ func (h *Handler) apiResearchGetDoc(w http.ResponseWriter, taskID, docID string)
|
||||||
"content": string(content),
|
"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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1381,15 +1381,22 @@ async function loadResearch() {
|
||||||
}
|
}
|
||||||
el.innerHTML = tasks.map(function(t) {
|
el.innerHTML = tasks.map(function(t) {
|
||||||
var sc = STATUS_COLORS[t.status] || STATUS_COLORS.pending;
|
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">' +
|
'<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-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:' +
|
'<span style="font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px;background:' +
|
||||||
sc.bg + ';color:' + sc.text + '">' + t.status + '</span>' +
|
sc.bg + ';color:' + sc.text + '">' + t.status + '</span>' +
|
||||||
'</div>' +
|
'</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>' : '') +
|
(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">' +
|
'<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>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
@ -1429,16 +1436,36 @@ async function openResearchTask(id) {
|
||||||
if (task.description) {
|
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: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() +
|
'Created: ' + new Date(task.created_at).toLocaleString() +
|
||||||
(task.completed_at ? ' · Completed: ' + new Date(task.completed_at).toLocaleString() : '') +
|
(task.completed_at ? ' · Completed: ' + new Date(task.completed_at).toLocaleString() : '') +
|
||||||
'</div>';
|
'</div>';
|
||||||
if (canCancel || canReopen) {
|
|
||||||
html += '<div style="margin-top:10px;display:flex;gap:8px">';
|
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 (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>';
|
if (canReopen) html += '<button class="worktree-btn merge" onclick="researchAction(\'' + id + '\',\'reopen\')">Reopen</button>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
}
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
// Documents
|
// Documents
|
||||||
|
|
@ -1461,6 +1488,13 @@ async function openResearchTask(id) {
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
el.innerHTML = html;
|
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) {
|
} catch (e) {
|
||||||
el.innerHTML = '<div class="empty-state">Failed to load task.</div>';
|
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) {
|
async function researchAction(taskId, action) {
|
||||||
try {
|
try {
|
||||||
await fetch(
|
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.showNewTaskForm = showNewTaskForm;
|
||||||
window.hideNewTaskForm = hideNewTaskForm;
|
window.hideNewTaskForm = hideNewTaskForm;
|
||||||
window.createResearchTask = createResearchTask;
|
window.createResearchTask = createResearchTask;
|
||||||
window.openResearchTask = openResearchTask;
|
window.openResearchTask = openResearchTask;
|
||||||
window.toggleResearchDoc = toggleResearchDoc;
|
window.toggleResearchDoc = toggleResearchDoc;
|
||||||
window.researchAction = researchAction;
|
window.researchAction = researchAction;
|
||||||
|
window.researchSetFocus = researchSetFocus;
|
||||||
window.showResearchList = showResearchList;
|
window.showResearchList = showResearchList;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ type Handler struct {
|
||||||
workspace string
|
workspace string
|
||||||
orchBroadcaster *orch.Broadcaster
|
orchBroadcaster *orch.Broadcaster
|
||||||
researchStore *research.ResearchStore
|
researchStore *research.ResearchStore
|
||||||
|
researchFocus *research.FocusTracker
|
||||||
|
|
||||||
devMu sync.RWMutex
|
devMu sync.RWMutex
|
||||||
devTarget *url.URL
|
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/console", h.apiDevConsole)
|
||||||
mux.HandleFunc("/miniapp/dev/", h.serveDevProxy)
|
mux.HandleFunc("/miniapp/dev/", h.serveDevProxy)
|
||||||
mux.HandleFunc("/miniapp/api/research", h.requireAuth(h.apiResearch))
|
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))
|
mux.HandleFunc("/miniapp/api/research/", h.requireAuth(h.apiResearchDetail))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
21
pkg/miniapp/static/dist/app.js
vendored
21
pkg/miniapp/static/dist/app.js
vendored
|
|
@ -8638,7 +8638,8 @@ Please report this to https://github.com/markedjs/marked.`, e) {
|
||||||
}
|
}
|
||||||
el.innerHTML = tasks.map(function(t) {
|
el.innerHTML = tasks.map(function(t) {
|
||||||
var sc = STATUS_COLORS[t.status] || STATUS_COLORS.pending;
|
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("");
|
}).join("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loading.classList.add("hidden");
|
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: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>";
|
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">';
|
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)
|
if (canCancel)
|
||||||
html += `<button class="worktree-btn dispose" onclick="researchAction('` + id + `','cancel')">Cancel</button>`;
|
html += `<button class="worktree-btn dispose" onclick="researchAction('` + id + `','cancel')">Cancel</button>`;
|
||||||
if (canReopen)
|
if (canReopen)
|
||||||
html += `<button class="worktree-btn merge" onclick="researchAction('` + id + `','reopen')">Reopen</button>`;
|
html += `<button class="worktree-btn merge" onclick="researchAction('` + id + `','reopen')">Reopen</button>`;
|
||||||
html += "</div>";
|
html += "</div>";
|
||||||
}
|
|
||||||
html += "</div>";
|
html += "</div>";
|
||||||
html += '<div class="card-title" style="margin-top:12px">Documents (' + task.documents.length + ")</div>";
|
html += '<div class="card-title" style="margin-top:12px">Documents (' + task.documents.length + ")</div>";
|
||||||
if (task.documents.length === 0) {
|
if (task.documents.length === 0) {
|
||||||
|
|
@ -8741,11 +8745,22 @@ Please report this to https://github.com/markedjs/marked.`, e) {
|
||||||
loadResearch();
|
loadResearch();
|
||||||
} catch (e) {}
|
} 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.showNewTaskForm = showNewTaskForm;
|
||||||
window.hideNewTaskForm = hideNewTaskForm;
|
window.hideNewTaskForm = hideNewTaskForm;
|
||||||
window.createResearchTask = createResearchTask;
|
window.createResearchTask = createResearchTask;
|
||||||
window.openResearchTask = openResearchTask;
|
window.openResearchTask = openResearchTask;
|
||||||
window.toggleResearchDoc = toggleResearchDoc;
|
window.toggleResearchDoc = toggleResearchDoc;
|
||||||
window.researchAction = researchAction;
|
window.researchAction = researchAction;
|
||||||
|
window.researchSetFocus = researchSetFocus;
|
||||||
window.showResearchList = showResearchList;
|
window.showResearchList = showResearchList;
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
65
pkg/research/focus.go
Normal file
65
pkg/research/focus.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -67,7 +67,69 @@ func OpenResearchStore(dbPath, workspace string) (*ResearchStore, error) {
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
return nil, fmt.Errorf("create schema: %w", err)
|
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, ¬Null, &dfltValue, &pk); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if name == column {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the database connection.
|
// 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.
|
// 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()
|
id := uuid.New().String()
|
||||||
slug := slugify(title)
|
slug := slugify(title)
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
@ -90,9 +160,9 @@ func (s *ResearchStore) CreateTask(title, description string) (*Task, error) {
|
||||||
|
|
||||||
nowStr := now.Format(time.RFC3339)
|
nowStr := now.Format(time.RFC3339)
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO research_tasks (id, title, slug, description, status, output_dir, created_at, updated_at)
|
`INSERT INTO research_tasks (id, title, slug, description, status, output_dir, interval, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
id, title, slug, description, string(StatusPending), outputDir, nowStr, nowStr,
|
id, title, slug, description, string(StatusPending), outputDir, interval, nowStr, nowStr,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("insert task: %w", err)
|
return nil, fmt.Errorf("insert task: %w", err)
|
||||||
|
|
@ -105,16 +175,18 @@ func (s *ResearchStore) CreateTask(title, description string) (*Task, error) {
|
||||||
Description: description,
|
Description: description,
|
||||||
Status: StatusPending,
|
Status: StatusPending,
|
||||||
OutputDir: outputDir,
|
OutputDir: outputDir,
|
||||||
|
Interval: interval,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}, nil
|
}, 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.
|
// GetTask retrieves a single task by ID.
|
||||||
func (s *ResearchStore) GetTask(id string) (*Task, error) {
|
func (s *ResearchStore) GetTask(id string) (*Task, error) {
|
||||||
row := s.db.QueryRow(
|
row := s.db.QueryRow(
|
||||||
`SELECT id, title, slug, description, status, output_dir, created_at, updated_at, completed_at
|
`SELECT `+taskColumns+` FROM research_tasks WHERE id = ?`, id)
|
||||||
FROM research_tasks WHERE id = ?`, id)
|
|
||||||
return scanTask(row)
|
return scanTask(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,12 +196,10 @@ func (s *ResearchStore) ListTasks(status TaskStatus) ([]*Task, error) {
|
||||||
var err error
|
var err error
|
||||||
if status == "" {
|
if status == "" {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, title, slug, description, status, output_dir, created_at, updated_at, completed_at
|
`SELECT ` + taskColumns + ` FROM research_tasks ORDER BY created_at DESC`)
|
||||||
FROM research_tasks ORDER BY created_at DESC`)
|
|
||||||
} else {
|
} else {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, title, slug, description, status, output_dir, created_at, updated_at, completed_at
|
`SELECT `+taskColumns+` FROM research_tasks WHERE status = ? ORDER BY created_at DESC`, string(status))
|
||||||
FROM research_tasks WHERE status = ? ORDER BY created_at DESC`, string(status))
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list tasks: %w", err)
|
return nil, fmt.Errorf("list tasks: %w", err)
|
||||||
|
|
@ -147,6 +217,33 @@ func (s *ResearchStore) ListTasks(status TaskStatus) ([]*Task, error) {
|
||||||
return tasks, rows.Err()
|
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.
|
// SetTaskStatus updates task status with transition validation.
|
||||||
func (s *ResearchStore) SetTaskStatus(id string, status TaskStatus) error {
|
func (s *ResearchStore) SetTaskStatus(id string, status TaskStatus) error {
|
||||||
task, err := s.GetTask(id)
|
task, err := s.GetTask(id)
|
||||||
|
|
@ -251,17 +348,65 @@ func (s *ResearchStore) DocumentCount(taskID string) (int, error) {
|
||||||
return count, err
|
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 ---
|
// --- helpers ---
|
||||||
|
|
||||||
func scanTask(row *sql.Row) (*Task, error) {
|
func scanTask(row *sql.Row) (*Task, error) {
|
||||||
var t Task
|
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,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
t.Status = TaskStatus(statusStr)
|
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.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||||
t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
||||||
if completedStr != "" {
|
if completedStr != "" {
|
||||||
|
|
@ -276,13 +421,17 @@ type rowScanner interface {
|
||||||
|
|
||||||
func scanTaskRow(row rowScanner) (*Task, error) {
|
func scanTaskRow(row rowScanner) (*Task, error) {
|
||||||
var t Task
|
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,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
t.Status = TaskStatus(statusStr)
|
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.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||||
t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
||||||
if completedStr != "" {
|
if completedStr != "" {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupTestStore(t *testing.T) (*ResearchStore, string) {
|
func setupTestStore(t *testing.T) (*ResearchStore, string) {
|
||||||
|
|
@ -21,7 +22,7 @@ func setupTestStore(t *testing.T) (*ResearchStore, string) {
|
||||||
func TestCreateAndGetTask(t *testing.T) {
|
func TestCreateAndGetTask(t *testing.T) {
|
||||||
store, dir := setupTestStore(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 {
|
if err != nil {
|
||||||
t.Fatalf("create task: %v", err)
|
t.Fatalf("create task: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -50,8 +51,8 @@ func TestCreateAndGetTask(t *testing.T) {
|
||||||
func TestListTasks(t *testing.T) {
|
func TestListTasks(t *testing.T) {
|
||||||
store, _ := setupTestStore(t)
|
store, _ := setupTestStore(t)
|
||||||
|
|
||||||
store.CreateTask("Task A", "")
|
store.CreateTask("Task A", "", "")
|
||||||
store.CreateTask("Task B", "")
|
store.CreateTask("Task B", "", "")
|
||||||
|
|
||||||
all, err := store.ListTasks("")
|
all, err := store.ListTasks("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -81,7 +82,7 @@ func TestListTasks(t *testing.T) {
|
||||||
func TestSetTaskStatus(t *testing.T) {
|
func TestSetTaskStatus(t *testing.T) {
|
||||||
store, _ := setupTestStore(t)
|
store, _ := setupTestStore(t)
|
||||||
|
|
||||||
task, _ := store.CreateTask("Status Test", "")
|
task, _ := store.CreateTask("Status Test", "", "")
|
||||||
|
|
||||||
// Valid: pending → active
|
// Valid: pending → active
|
||||||
if err := store.SetTaskStatus(task.ID, StatusActive); err != nil {
|
if err := store.SetTaskStatus(task.ID, StatusActive); err != nil {
|
||||||
|
|
@ -113,7 +114,7 @@ func TestSetTaskStatus(t *testing.T) {
|
||||||
func TestAddAndListDocuments(t *testing.T) {
|
func TestAddAndListDocuments(t *testing.T) {
|
||||||
store, dir := setupTestStore(t)
|
store, dir := setupTestStore(t)
|
||||||
|
|
||||||
task, _ := store.CreateTask("Doc Test", "")
|
task, _ := store.CreateTask("Doc Test", "", "")
|
||||||
|
|
||||||
// Create a test file
|
// Create a test file
|
||||||
filePath := filepath.Join(dir, task.OutputDir, "001-finding.md")
|
filePath := filepath.Join(dir, task.OutputDir, "001-finding.md")
|
||||||
|
|
@ -144,7 +145,7 @@ func TestAddAndListDocuments(t *testing.T) {
|
||||||
func TestDocumentCount(t *testing.T) {
|
func TestDocumentCount(t *testing.T) {
|
||||||
store, _ := setupTestStore(t)
|
store, _ := setupTestStore(t)
|
||||||
|
|
||||||
task, _ := store.CreateTask("Count Test", "")
|
task, _ := store.CreateTask("Count Test", "", "")
|
||||||
|
|
||||||
count, _ := store.DocumentCount(task.ID)
|
count, _ := store.DocumentCount(task.ID)
|
||||||
if count != 0 {
|
if count != 0 {
|
||||||
|
|
@ -163,7 +164,7 @@ func TestDocumentCount(t *testing.T) {
|
||||||
func TestDeleteTask(t *testing.T) {
|
func TestDeleteTask(t *testing.T) {
|
||||||
store, _ := setupTestStore(t)
|
store, _ := setupTestStore(t)
|
||||||
|
|
||||||
task, _ := store.CreateTask("Delete Test", "")
|
task, _ := store.CreateTask("Delete Test", "", "")
|
||||||
store.AddDocument(task.ID, "D1", "p1", "finding", "")
|
store.AddDocument(task.ID, "D1", "p1", "finding", "")
|
||||||
|
|
||||||
if err := store.DeleteTask(task.ID); err != nil {
|
if err := store.DeleteTask(task.ID); err != nil {
|
||||||
|
|
@ -180,7 +181,7 @@ func TestDeleteTask(t *testing.T) {
|
||||||
func TestUpdateTask(t *testing.T) {
|
func TestUpdateTask(t *testing.T) {
|
||||||
store, _ := setupTestStore(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 {
|
if err := store.UpdateTask(task.ID, "Updated", "new desc"); err != nil {
|
||||||
t.Fatalf("update: %v", err)
|
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) {
|
func TestCanTransition(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
from, to TaskStatus
|
from, to TaskStatus
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
package research
|
package research
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// TaskStatus represents the lifecycle state of a research task.
|
// TaskStatus represents the lifecycle state of a research task.
|
||||||
type TaskStatus string
|
type TaskStatus string
|
||||||
|
|
@ -13,6 +18,16 @@ const (
|
||||||
StatusCanceled TaskStatus = "canceled"
|
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.
|
// Task represents a research task tracked in the database.
|
||||||
type Task struct {
|
type Task struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|
@ -21,11 +36,44 @@ type Task struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status TaskStatus `json:"status"`
|
Status TaskStatus `json:"status"`
|
||||||
OutputDir string `json:"output_dir"`
|
OutputDir string `json:"output_dir"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
LastResearchedAt time.Time `json:"last_researched_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
CompletedAt time.Time `json:"completed_at,omitempty"`
|
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.
|
// Document represents a research output document linked to a task.
|
||||||
type Document struct {
|
type Document struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|
|
||||||
59
pkg/tools/context_heartbeat.go
Normal file
59
pkg/tools/context_heartbeat.go
Normal 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
|
||||||
|
}
|
||||||
82
pkg/tools/context_heartbeat_test.go
Normal file
82
pkg/tools/context_heartbeat_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,20 +12,27 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResearchTool provides research task management for the agent.
|
// 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 {
|
type ResearchTool struct {
|
||||||
store *research.ResearchStore
|
store *research.ResearchStore
|
||||||
workspace string
|
workspace string
|
||||||
|
focus *research.FocusTracker
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewResearchTool creates a new ResearchTool.
|
// NewResearchTool creates a new ResearchTool.
|
||||||
func NewResearchTool(store *research.ResearchStore, workspace string) *ResearchTool {
|
func NewResearchTool(store *research.ResearchStore, workspace string, focus *research.FocusTracker) *ResearchTool {
|
||||||
return &ResearchTool{store: store, workspace: workspace}
|
return &ResearchTool{
|
||||||
|
store: store,
|
||||||
|
workspace: workspace,
|
||||||
|
focus: focus,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ResearchTool) Name() string { return "research" }
|
func (t *ResearchTool) Name() string { return "research" }
|
||||||
|
|
||||||
func (t *ResearchTool) Description() string {
|
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 {
|
func (t *ResearchTool) Parameters() map[string]any {
|
||||||
|
|
@ -34,12 +41,28 @@ func (t *ResearchTool) Parameters() map[string]any {
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"action": map[string]any{
|
"action": map[string]any{
|
||||||
"type": "string",
|
"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.",
|
"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{
|
"task_id": map[string]any{
|
||||||
"type": "string",
|
"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{
|
"status_filter": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
@ -76,14 +99,50 @@ func (t *ResearchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
case "get_task":
|
case "get_task":
|
||||||
return t.getTask(args)
|
return t.getTask(args)
|
||||||
case "set_status":
|
case "set_status":
|
||||||
return t.setStatus(args)
|
return t.setStatus(ctx, args)
|
||||||
|
case "set_interval":
|
||||||
|
return t.setInterval(args)
|
||||||
case "add_finding":
|
case "add_finding":
|
||||||
return t.addFinding(args)
|
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:
|
default:
|
||||||
return ErrorResult("unknown action: " + action)
|
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 {
|
func (t *ResearchTool) listTasks(args map[string]any) *ToolResult {
|
||||||
filter, _ := args["status_filter"].(string)
|
filter, _ := args["status_filter"].(string)
|
||||||
tasks, err := t.store.ListTasks(research.TaskStatus(filter))
|
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())
|
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)
|
taskID, _ := args["task_id"].(string)
|
||||||
status, _ := args["status"].(string)
|
status, _ := args["status"].(string)
|
||||||
if taskID == "" || status == "" {
|
if taskID == "" || status == "" {
|
||||||
return ErrorResult("task_id and status are required")
|
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 {
|
if err := t.store.SetTaskStatus(taskID, research.TaskStatus(status)); err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("set status: %v", err))
|
return ErrorResult(fmt.Sprintf("set status: %v", err))
|
||||||
}
|
}
|
||||||
return NewToolResult(fmt.Sprintf("Task status updated to %s.", status))
|
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_-]+`)
|
var sanitizeRe = regexp.MustCompile(`[^a-zA-Z0-9_-]+`)
|
||||||
|
|
||||||
func (t *ResearchTool) addFinding(args map[string]any) *ToolResult {
|
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))
|
return ErrorResult(fmt.Sprintf("add document: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Touch last_researched_at for interval tracking
|
||||||
|
_ = t.store.TouchLastResearched(taskID)
|
||||||
|
|
||||||
return NewToolResult(fmt.Sprintf(
|
return NewToolResult(fmt.Sprintf(
|
||||||
"Finding recorded:\n- File: %s\n- Document ID: %s\n- Seq: %d",
|
"Finding recorded:\n- File: %s\n- Document ID: %s\n- Seq: %d",
|
||||||
relPath, doc.ID, doc.Seq,
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -834,6 +834,15 @@ func (t *WebSearchTool) Parameters() map[string]any {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
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)
|
query, ok := args["query"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrorResult("query is required")
|
return ErrorResult("query is required")
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ type researchTaskJSON struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
OutputDir string `json:"output_dir"`
|
OutputDir string `json:"output_dir"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
LastResearchedAt string `json:"last_researched_at,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
CompletedAt string `json:"completed_at,omitempty"`
|
CompletedAt string `json:"completed_at,omitempty"`
|
||||||
|
|
@ -60,10 +62,14 @@ func taskToJSON(t *research.Task, docCount int) researchTaskJSON {
|
||||||
Description: t.Description,
|
Description: t.Description,
|
||||||
Status: string(t.Status),
|
Status: string(t.Status),
|
||||||
OutputDir: t.OutputDir,
|
OutputDir: t.OutputDir,
|
||||||
|
Interval: t.Interval,
|
||||||
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
DocumentCount: docCount,
|
DocumentCount: docCount,
|
||||||
}
|
}
|
||||||
|
if !t.LastResearchedAt.IsZero() {
|
||||||
|
r.LastResearchedAt = t.LastResearchedAt.Format("2006-01-02T15:04:05Z")
|
||||||
|
}
|
||||||
if !t.CompletedAt.IsZero() {
|
if !t.CompletedAt.IsZero() {
|
||||||
r.CompletedAt = t.CompletedAt.Format("2006-01-02T15:04:05Z")
|
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 {
|
var req struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
||||||
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
|
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)
|
http.Error(w, `{"error":"title is required"}`, http.StatusBadRequest)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ export interface ResearchTask {
|
||||||
description: string
|
description: string
|
||||||
status: "pending" | "active" | "completed" | "failed" | "canceled"
|
status: "pending" | "active" | "completed" | "failed" | "canceled"
|
||||||
output_dir: string
|
output_dir: string
|
||||||
|
interval: string
|
||||||
|
last_researched_at?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
completed_at?: string
|
completed_at?: string
|
||||||
|
|
@ -68,18 +70,19 @@ export async function getResearchTask(
|
||||||
export async function createResearchTask(
|
export async function createResearchTask(
|
||||||
title: string,
|
title: string,
|
||||||
description: string,
|
description: string,
|
||||||
|
interval?: string,
|
||||||
): Promise<ResearchTask> {
|
): Promise<ResearchTask> {
|
||||||
return request<ResearchTask>("/api/research", {
|
return request<ResearchTask>("/api/research", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ title, description }),
|
body: JSON.stringify({ title, description, interval }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function researchTaskAction(
|
export async function researchTaskAction(
|
||||||
id: string,
|
id: string,
|
||||||
action: "cancel" | "reopen" | "update",
|
action: "cancel" | "reopen" | "update" | "set_interval",
|
||||||
data?: { title?: string; description?: string },
|
data?: { title?: string; description?: string; interval?: string },
|
||||||
): Promise<ResearchTaskDetail> {
|
): Promise<ResearchTaskDetail> {
|
||||||
return request<ResearchTaskDetail>(
|
return request<ResearchTaskDetail>(
|
||||||
`/api/research/${encodeURIComponent(id)}`,
|
`/api/research/${encodeURIComponent(id)}`,
|
||||||
|
|
|
||||||
|
|
@ -218,9 +218,13 @@ function TaskCard({ task }: { task: ResearchTask }) {
|
||||||
count: task.document_count,
|
count: task.document_count,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
<span>⏱ {task.interval || "24h"}</span>
|
||||||
|
{task.last_researched_at ? (
|
||||||
<span>
|
<span>
|
||||||
{new Date(task.created_at).toLocaleDateString()}
|
{t("pages.research.last_researched")}:{" "}
|
||||||
|
{new Date(task.last_researched_at).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,13 @@ export function TaskDetailPage({ taskId }: { taskId: string }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground space-y-1 text-xs">
|
<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>
|
<div>
|
||||||
{t("pages.research.created_at")}:{" "}
|
{t("pages.research.created_at")}:{" "}
|
||||||
{new Date(task.created_at).toLocaleString()}
|
{new Date(task.created_at).toLocaleString()}
|
||||||
|
|
|
||||||
|
|
@ -474,6 +474,8 @@
|
||||||
"documents_title": "Documents ({{count}})",
|
"documents_title": "Documents ({{count}})",
|
||||||
"documents_count": "{{count}} docs",
|
"documents_count": "{{count}} docs",
|
||||||
"no_documents": "No documents yet.",
|
"no_documents": "No documents yet.",
|
||||||
|
"interval": "Interval",
|
||||||
|
"last_researched": "Last researched",
|
||||||
"created_at": "Created",
|
"created_at": "Created",
|
||||||
"completed_at": "Completed",
|
"completed_at": "Completed",
|
||||||
"output_dir": "Output",
|
"output_dir": "Output",
|
||||||
|
|
|
||||||
|
|
@ -474,6 +474,8 @@
|
||||||
"documents_title": "文档 ({{count}})",
|
"documents_title": "文档 ({{count}})",
|
||||||
"documents_count": "{{count}} 篇文档",
|
"documents_count": "{{count}} 篇文档",
|
||||||
"no_documents": "暂无文档。",
|
"no_documents": "暂无文档。",
|
||||||
|
"interval": "调查频率",
|
||||||
|
"last_researched": "最后调查",
|
||||||
"created_at": "创建时间",
|
"created_at": "创建时间",
|
||||||
"completed_at": "完成时间",
|
"completed_at": "完成时间",
|
||||||
"output_dir": "输出目录",
|
"output_dir": "输出目录",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue