diff --git a/cmd/antidriftd/main.go b/cmd/antidriftd/main.go index 7a81b04..ef29757 100644 --- a/cmd/antidriftd/main.go +++ b/cmd/antidriftd/main.go @@ -12,6 +12,7 @@ import ( "antidrift/internal/ai" "antidrift/internal/evidence" + "antidrift/internal/knowledge" "antidrift/internal/session" "antidrift/internal/statusfile" "antidrift/internal/store" @@ -54,6 +55,13 @@ func main() { ctrl.SetTasks(tasks.NewMarvin(os.Getenv("ANTIDRIFT_MARVIN_CMD"))) log.Printf("tasks: marvin adapter (am)") + // Wire the Knowledge port: a single profile file grounding the coach. The + // default path is overridable with ANTIDRIFT_KNOWLEDGE_FILE; unset falls back + // to ~/.antidrift/knowledge.md. A missing/unreadable file degrades to an + // ungrounded coach at load time — planning still works. + ctrl.SetKnowledge(knowledge.NewFileSource(os.Getenv("ANTIDRIFT_KNOWLEDGE_FILE"))) + log.Printf("knowledge: file adapter") + // Mirror runtime status to ~/.antidrift_status for a window-manager bar. // A misconfigured home dir degrades to no status file, not a failed start. if statusPath, err := statusfile.DefaultPath(); err != nil { diff --git a/internal/web/web.go b/internal/web/web.go index f887b23..1a5a207 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -58,6 +58,7 @@ func (s *Server) Router() *gin.Engine { r.POST("/end", s.handleEnd) r.POST("/refocus", s.handleRefocus) r.POST("/ontask", s.handleOnTask) + r.POST("/knowledge/path", s.handleKnowledgePath) return r } @@ -124,6 +125,24 @@ func (s *Server) handleCommitment(c *gin.Context) { s.respond(c, err) } +type knowledgePathRequest struct { + Path string `json:"path"` +} + +// handleKnowledgePath repoints the profile file at runtime (session-only). It +// mutates config, not commitment state, so it never returns a transition error; +// it just sets the path, re-loads, and broadcasts the refreshed state. +func (s *Server) handleKnowledgePath(c *gin.Context) { + var req knowledgePathRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) + return + } + s.ctrl.SetKnowledgePath(req.Path) + s.broadcast() + c.Data(http.StatusOK, "application/json", []byte(s.stateJSON())) +} + func (s *Server) handleComplete(c *gin.Context) { s.cancelExpiry() s.respond(c, s.ctrl.Complete()) diff --git a/internal/web/web_test.go b/internal/web/web_test.go index da9049a..b99ae81 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -12,6 +12,7 @@ import ( "antidrift/internal/ai" "antidrift/internal/domain" "antidrift/internal/evidence" + "antidrift/internal/knowledge" "antidrift/internal/session" "antidrift/internal/tasks" @@ -108,7 +109,7 @@ type stubCoach struct { err error } -func (s stubCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) { +func (s stubCoach) Coach(ctx context.Context, intent, grounding string) (ai.Proposal, error) { return s.prop, s.err } @@ -281,3 +282,57 @@ func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) { t.Fatalf("refocus while Active: want 200, got %d", w.Code) } } + +type stubSource struct { + profile knowledge.Profile +} + +func (s stubSource) Load(ctx context.Context, path string) (knowledge.Profile, error) { + if path != "" { + return knowledge.Profile{Text: "from " + path, Path: path}, nil + } + return s.profile, nil +} + +func TestPlanningStatePayloadCarriesKnowledge(t *testing.T) { + s := newTestServer(t) + s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.antidrift/knowledge.md"}}) + r := s.Router() + if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { + t.Fatalf("/planning code %d", w.Code) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if kv := s.ctrl.State().Knowledge; kv != nil && kv.Status == "ready" { + break + } + time.Sleep(5 * time.Millisecond) + } + js := s.stateJSON() + if !strings.Contains(js, `"knowledge"`) || !strings.Contains(js, `"status":"ready"`) { + t.Fatalf("planning payload missing knowledge object: %s", js) + } + if strings.Contains(js, "small diffs") { + t.Fatalf("profile text must NOT cross the wire: %s", js) + } +} + +func TestKnowledgePathSelectionReloads(t *testing.T) { + s := newTestServer(t) + s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}}) + r := s.Router() + if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { + t.Fatalf("/planning code %d", w.Code) + } + if w := post(t, r, "/knowledge/path", `{"path":"/custom/profile.md"}`); w.Code != http.StatusOK { + t.Fatalf("/knowledge/path code %d body %s", w.Code, w.Body.String()) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if kv := s.ctrl.State().Knowledge; kv != nil && kv.Path == "/custom/profile.md" { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("path selection did not reload: %+v", s.ctrl.State().Knowledge) +}