diff --git a/cmd/nomos/store.go b/cmd/nomos/store.go index a3f3265..4a00f80 100644 --- a/cmd/nomos/store.go +++ b/cmd/nomos/store.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "time" "github.com/google/uuid" @@ -37,10 +38,18 @@ func (s *store) close() { } } +// session is a chat session elevated to a task: goal-structured work with a +// lifecycle status and an outcome (see migration 018 / the task-board plan). +// Outcome/Summary/EntityID are empty until set, hence omitempty. type session struct { ID string `json:"id"` Title string `json:"title"` Actor string `json:"actor"` + Goal string `json:"goal"` + Status string `json:"status"` + Outcome string `json:"outcome,omitempty"` + Summary string `json:"summary,omitempty"` + EntityID string `json:"entity_id,omitempty"` CreatedAt time.Time `json:"created_at"` LastActiveAt time.Time `json:"last_active_at"` } @@ -55,7 +64,7 @@ type message struct { func (s *store) createSession(ctx context.Context, title string) (*session, error) { if s == nil { - return &session{ID: "ephemeral", Title: title, Actor: "agent:nomos"}, nil + return &session{ID: "ephemeral", Title: title, Actor: "agent:nomos", Status: "active"}, nil } var id string err := s.pool.QueryRow(ctx, @@ -64,7 +73,38 @@ func (s *store) createSession(ctx context.Context, title string) (*session, erro if err != nil { return nil, err } - return &session{ID: id, Title: title, Actor: "agent:nomos", CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil + // Give the task its own entity so knowledge and involved-entity edges hang + // off the existing relationships graph. Best-effort: a failure here must not + // block the chat — the session is usable without a graph anchor. + entityID := s.createTaskEntity(ctx, id, title) + return &session{ID: id, Title: title, Actor: "agent:nomos", Status: "active", + EntityID: entityID, CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil +} + +// createTaskEntity creates (or reuses) the task: entity that +// anchors this task's knowledge and involved-entity relationships, and records +// it on the session. Returns the entity id, or "" on failure — non-fatal, see +// caller. Requires the 'task' entity type (seeds/ontology.yaml). +func (s *store) createTaskEntity(ctx context.Context, sessionID, title string) string { + entityID, _ := uuid.NewV7() + slug := "task:" + sessionID + // name is UNIQUE(type,name) and chat titles collide ("hi" ×6), so key the + // name on the session id and keep the human title in attributes for display. + name := "task " + sessionID + attrs, _ := json.Marshal(map[string]any{"title": title}) + if err := s.pool.QueryRow(ctx, ` + INSERT INTO entities (id, slug, type, name, attributes) + VALUES ($1, $2, 'task', $3, $4) + ON CONFLICT (slug) DO UPDATE SET updated_at = now() + RETURNING id`, entityID, slug, name, string(attrs)).Scan(&entityID); err != nil { + slog.Warn("nomos: could not create task entity", "session", sessionID, "error", err) + return "" + } + if _, err := s.pool.Exec(ctx, + `UPDATE agent_sessions SET entity_id = $1 WHERE id = $2`, entityID, sessionID); err != nil { + slog.Warn("nomos: could not link task entity", "session", sessionID, "error", err) + } + return entityID.String() } func (s *store) saveMessage(ctx context.Context, sessionID, role string, content json.RawMessage) error { @@ -151,7 +191,9 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) { return nil, nil } rows, err := s.pool.Query(ctx, - `SELECT id, title, actor, created_at, last_active_at FROM agent_sessions ORDER BY last_active_at DESC LIMIT 50`) + `SELECT id, title, actor, goal, status, COALESCE(outcome, ''), summary, + COALESCE(entity_id::text, ''), created_at, last_active_at + FROM agent_sessions ORDER BY last_active_at DESC LIMIT 50`) if err != nil { return nil, err } @@ -160,7 +202,8 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) { var out []session for rows.Next() { var sess session - if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.CreatedAt, &sess.LastActiveAt); err != nil { + if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status, + &sess.Outcome, &sess.Summary, &sess.EntityID, &sess.CreatedAt, &sess.LastActiveAt); err != nil { return nil, err } out = append(out, sess) diff --git a/migrations/018_tasks.up.sql b/migrations/018_tasks.up.sql new file mode 100644 index 0000000..c3f5f31 --- /dev/null +++ b/migrations/018_tasks.up.sql @@ -0,0 +1,53 @@ +-- 018_tasks.up.sql +-- Elevate a chat session into a "task": a goal-structured unit of work with a +-- lifecycle status, an outcome, and a one-line summary — the first-class object +-- the task board and the live context panel render. See +-- plans/2026-07-11-goal-oriented-chat-control-panel.md. +-- +-- entity_id links the session to its OWN entity (type 'task', registered in +-- seeds/ontology.yaml) so knowledge notes and involved-entity edges hang off +-- the existing relationships graph unchanged — get_relations and +-- get_entity_knowledge just work. Intentionally no hard FK (mirrors 017's +-- decoupling): a race between task-entity creation and the session insert must +-- not be able to break the session. + +ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS goal TEXT NOT NULL DEFAULT ''; +ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'; +ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS outcome TEXT; +ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS summary TEXT NOT NULL DEFAULT ''; +ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS entity_id UUID; + +-- Ordered plan steps. A step is a described unit of work that maps to a +-- run/request_execution call (no fixed step enum, per general-gated-execution). +-- execution_id is the gated action a step runs, if any; its terminal status +-- auto-closes the step server-side. +CREATE TABLE IF NOT EXISTS session_plan_steps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE, + seq INT NOT NULL, + title TEXT NOT NULL, + detail TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + -- pending | running | done | failed | skipped | blocked + execution_id UUID, + target_slug TEXT, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_plan_steps_session ON session_plan_steps(session_id, seq); + +-- Structured decisions the agent surfaces to the operator mid-task. context +-- carries { entities:[], options:[], why:"" } for the pinned question card. +CREATE TABLE IF NOT EXISTS session_questions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE, + prompt TEXT NOT NULL, + context JSONB NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'open', -- open | answered | dismissed + answer TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + answered_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_questions_session_open + ON session_questions(session_id) WHERE status = 'open'; diff --git a/seeds/ontology.yaml b/seeds/ontology.yaml index 9e3b64e..4cdb4e0 100644 --- a/seeds/ontology.yaml +++ b/seeds/ontology.yaml @@ -641,6 +641,14 @@ entity_types: domain: cognition layer: cognition description: Recorded investigation/postmortem. + task: + parent: entity + domain: cognition + layer: cognition + description: A goal-structured unit of agent work — one chat/session elevated + to a task with a plan, lifecycle status, and outcome. Anchors the knowledge + and involved-entity relationships for the task so future tasks can learn + from it. Typed rows in agent_sessions. # ─── Relationship types ──────────────────────────────────────────────── # cardinality is source→target: e.g. `hosts` one-to-many = one machine @@ -928,6 +936,14 @@ relationship_types: target: entity cardinality: many-to-one description: Document describes an entity. + involves: + inverse: involved-in + source: task + target: entity + cardinality: many-to-many + description: Task explored or acted on an entity (captured from its tool + calls). A task's involved-entity set is its graph neighborhood, so future + tasks on the same entities can surface this task's knowledge and outcome. procedure-for: inverse: has-procedure source: runbook