Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/chat_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ func essentialTools() []tool.Tool {
tool.ToolSearchTool{},
tool.SkillTool{},
tool.SessionQueryTool{},
tool.ScheduleCreateTool{},
tool.ScheduleListTool{},
tool.ScheduleDeleteTool{},
tool.AgentTool{},
tool.AskUserQuestionTool{},
tool.TodoWriteTool{},
Expand Down
38 changes: 38 additions & 0 deletions internal/engine/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/GrayCodeAI/hawk/internal/prompts"
"github.com/GrayCodeAI/hawk/internal/resilience/ratelimit"
"github.com/GrayCodeAI/hawk/internal/sandbox"
"github.com/GrayCodeAI/hawk/internal/schedule"
"github.com/GrayCodeAI/hawk/internal/session"
"github.com/GrayCodeAI/hawk/internal/snapshot"
"github.com/GrayCodeAI/hawk/internal/tool"
Expand Down Expand Up @@ -138,6 +139,9 @@ type Session struct {
// emitted into the transcript (DSH tool-skill catalog digest).
lastSkillCatalogDigest string

// scheduleManager coordinates session-log-backed schedule timers.
scheduleManager *schedule.Manager

// Control plane (product modes) — orthogonal to SpecStage and shellmode.
workMode WorkMode
isolation IsolationProfile
Expand Down Expand Up @@ -1079,6 +1083,40 @@ func (s *Session) Chat(ctx context.Context, msgs []types.EyrieMessage, opts type
return s.ChatLLM().Chat(ctx, msgs, opts)
}

// Schedule returns the session's in-conversation schedule manager.
func (s *Session) Schedule() *schedule.Manager {
if s == nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.scheduleManager == nil {
s.scheduleManager = schedule.NewManager()
var j *eventlog.Log
if p := s.Persistence(); p != nil {
j = p.Journal()
}
s.scheduleManager.Attach(j, func(item schedule.Item) error {
content := fmt.Sprintf("[Scheduled Reminder: %s]\n%s", item.ID, item.Prompt)
if p := s.Persistence(); p != nil {
if sq := p.Steering(); sq != nil {
sq.Enqueue(SteeringMessage{
Content: content,
Priority: 1,
})
} else {
p.AppendUserJournaled(types.EyrieMessage{
Role: "user",
Content: content,
})
}
}
return nil
})
}
return s.scheduleManager
}

// RemoveLastExchange removes the last user+assistant message pair.
func (s *Session) RemoveLastExchange() {
msgs := s.Persistence().RawMessages()
Expand Down
9 changes: 9 additions & 0 deletions internal/eventlog/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ const (
// ScheduleChange records a schedule configuration change. Ported from
// DeepSeek Harness schedule/change.
ScheduleChange Type = "schedule.change"
// ScheduleCreate records an in-conversation schedule creation.
ScheduleCreate Type = "schedule.create"
// ScheduleUpdate records an in-conversation schedule update.
ScheduleUpdate Type = "schedule.update"
// ScheduleDelete records an in-conversation schedule deletion.
ScheduleDelete Type = "schedule.delete"
// ScheduleDue records that an in-conversation schedule reminder was triggered.
ScheduleDue Type = "schedule.due"
// SessionTitle records a log-backed session title. Ported from DeepSeek
// Harness session/title.
SessionTitle Type = "session.title"
Expand Down Expand Up @@ -166,6 +174,7 @@ func (t Type) Known() bool {
CompactionStart, CompactionPrune, CompactionEnd, CompactionSummary,
SessionEndSeed, TodoWrite, RequestHeader, HookInvoked, HookResult,
FeedbackRecord, GoalChange, PermissionPreset, SandboxMode, ScheduleChange,
ScheduleCreate, ScheduleUpdate, ScheduleDelete, ScheduleDue,
SessionTitle, SessionTitleLLMRequest, SubagentDescriptor, AgentPresetSelected,
AgentInboxSpliced, CommandRun, CommandDone,
ToolWorkflowAgentStart, ToolWorkflowAgentEnd,
Expand Down
65 changes: 65 additions & 0 deletions internal/eventlog/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
// plus plugin packages: compaction, hooks, feedback, goal, subagent, shell, preset.
package eventlog

import "time"

// --- Compaction lifecycle ---
// DeepSeek Harness models compaction as a four-stage lifecycle:
// start → prune → end → summary. Hawk previously recorded only a single
Expand Down Expand Up @@ -599,3 +601,66 @@ func (l *Log) AppendPlanMode(active bool) {
}
l.Append(PlanMode, PlanModeFact{Active: active})
}

// --- In-conversation schedule ---

// ScheduleCreateFact records an in-conversation schedule creation.
type ScheduleCreateFact struct {
ID string `json:"id"`
Prompt string `json:"prompt"`
DueAt time.Time `json:"due_at"`
Interval string `json:"interval,omitempty"`
Recurring bool `json:"recurring,omitempty"`
}

// AppendScheduleCreate records schedule creation.
func (l *Log) AppendScheduleCreate(fact ScheduleCreateFact) {
if l == nil {
return
}
l.Append(ScheduleCreate, fact)
}

// ScheduleUpdateFact records an in-conversation schedule update.
type ScheduleUpdateFact struct {
ID string `json:"id"`
Prompt *string `json:"prompt,omitempty"`
DueAt *time.Time `json:"due_at,omitempty"`
}

// AppendScheduleUpdate records schedule update.
func (l *Log) AppendScheduleUpdate(fact ScheduleUpdateFact) {
if l == nil {
return
}
l.Append(ScheduleUpdate, fact)
}

// ScheduleDeleteFact records an in-conversation schedule deletion.
type ScheduleDeleteFact struct {
ID string `json:"id"`
Reason string `json:"reason,omitempty"`
}

// AppendScheduleDelete records schedule deletion.
func (l *Log) AppendScheduleDelete(id, reason string) {
if l == nil {
return
}
l.Append(ScheduleDelete, ScheduleDeleteFact{ID: id, Reason: reason})
}

// ScheduleDueFact records that an in-conversation schedule became due and was delivered.
type ScheduleDueFact struct {
ID string `json:"id"`
DeliveredAt time.Time `json:"delivered_at"`
NextDueAt *time.Time `json:"next_due_at,omitempty"`
}

// AppendScheduleDue records schedule reminder delivery.
func (l *Log) AppendScheduleDue(fact ScheduleDueFact) {
if l == nil {
return
}
l.Append(ScheduleDue, fact)
}
4 changes: 4 additions & 0 deletions internal/eventlog/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ func TestNewEventTypesKnown(t *testing.T) {
"permission.preset": PermissionPreset,
"sandbox.mode": SandboxMode,
"schedule.change": ScheduleChange,
"schedule.create": ScheduleCreate,
"schedule.update": ScheduleUpdate,
"schedule.delete": ScheduleDelete,
"schedule.due": ScheduleDue,
"session.title": SessionTitle,
"session.title-llm-request": SessionTitleLLMRequest,
"subagent.descriptor": SubagentDescriptor,
Expand Down
24 changes: 24 additions & 0 deletions internal/eventlog/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,30 @@ func decodePayload(w WireEvent) (any, error) {
return nil, err
}
return p, nil
case ScheduleCreate:
var p ScheduleCreateFact
if err := json.Unmarshal(w.Data, &p); err != nil {
return nil, err
}
return p, nil
case ScheduleUpdate:
var p ScheduleUpdateFact
if err := json.Unmarshal(w.Data, &p); err != nil {
return nil, err
}
return p, nil
case ScheduleDelete:
var p ScheduleDeleteFact
if err := json.Unmarshal(w.Data, &p); err != nil {
return nil, err
}
return p, nil
case ScheduleDue:
var p ScheduleDueFact
if err := json.Unmarshal(w.Data, &p); err != nil {
return nil, err
}
return p, nil
case SessionTitle:
var p SessionTitleFact
if err := json.Unmarshal(w.Data, &p); err != nil {
Expand Down
121 changes: 121 additions & 0 deletions internal/schedule/fold.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package schedule

import (
"encoding/json"

"github.com/GrayCodeAI/hawk/internal/eventlog"
)

// Fold folds a sequence of eventlog events into a map of active ScheduleItems.
func Fold(events []eventlog.Event) map[string]Item {
items := make(map[string]Item)

for _, ev := range events {
switch ev.Type {
case eventlog.ScheduleCreate:
var fact eventlog.ScheduleCreateFact
if err := decodeFact(ev.Data, &fact); err == nil && fact.ID != "" {
items[fact.ID] = Item{
ID: fact.ID,
Prompt: fact.Prompt,
DueAt: fact.DueAt,
Interval: fact.Interval,
Recurring: fact.Recurring,
CreatedAt: ev.At,
UpdatedAt: ev.At,
Deleted: false,
}
}

case eventlog.ScheduleUpdate:
var fact eventlog.ScheduleUpdateFact
if err := decodeFact(ev.Data, &fact); err == nil && fact.ID != "" {
if item, exists := items[fact.ID]; exists && !item.Deleted {
if fact.Prompt != nil {
item.Prompt = *fact.Prompt
}
if fact.DueAt != nil {
item.DueAt = *fact.DueAt
}
item.UpdatedAt = ev.At
items[fact.ID] = item
}
}

case eventlog.ScheduleDelete:
var fact eventlog.ScheduleDeleteFact
if err := decodeFact(ev.Data, &fact); err == nil && fact.ID != "" {
if item, exists := items[fact.ID]; exists {
item.Deleted = true
item.UpdatedAt = ev.At
items[fact.ID] = item
}
}

case eventlog.ScheduleDue:
var fact eventlog.ScheduleDueFact
if err := decodeFact(ev.Data, &fact); err == nil && fact.ID != "" {
if item, exists := items[fact.ID]; exists && !item.Deleted {
deliveredAt := fact.DeliveredAt
if deliveredAt.IsZero() {
deliveredAt = ev.At
}
item.LastDueAt = &deliveredAt
item.UpdatedAt = ev.At

if fact.NextDueAt != nil && !fact.NextDueAt.IsZero() {
item.DueAt = *fact.NextDueAt
} else if !item.Recurring {
// One-shot schedule marked completed/deleted
item.Deleted = true
}
items[fact.ID] = item
}
}
}
}

// Filter out deleted/completed items
active := make(map[string]Item)
for id, item := range items {
if !item.Deleted {
active[id] = item
}
}
return active
}

func decodeFact(src any, dst any) error {
if src == nil {
return nil
}
switch s := src.(type) {
case eventlog.ScheduleCreateFact:
if d, ok := dst.(*eventlog.ScheduleCreateFact); ok {
*d = s
return nil
}
case eventlog.ScheduleUpdateFact:
if d, ok := dst.(*eventlog.ScheduleUpdateFact); ok {
*d = s
return nil
}
case eventlog.ScheduleDeleteFact:
if d, ok := dst.(*eventlog.ScheduleDeleteFact); ok {
*d = s
return nil
}
case eventlog.ScheduleDueFact:
if d, ok := dst.(*eventlog.ScheduleDueFact); ok {
*d = s
return nil
}
}

// Fallback to JSON conversion for map[string]any or wire payloads
data, err := json.Marshal(src)
if err != nil {
return err
}
return json.Unmarshal(data, dst)
}
16 changes: 16 additions & 0 deletions internal/schedule/global.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package schedule

import "sync"

var (
defaultManager *Manager
defaultManagerOnce sync.Once
)

// DefaultManager returns the global fallback schedule manager.
func DefaultManager() *Manager {
defaultManagerOnce.Do(func() {
defaultManager = NewManager()
})
return defaultManager
}
Loading
Loading