feat(nest): POST/GET nest messages, Jev-first tagging, event-driven crow response (#10)

This commit is contained in:
Smokey committed 2026-10-28 03:34:37 -06:00
1 parent ba5dbbc7ae
commit e4e4d12dea
5 files changed
+281 -1

No files matched your search

+1
View File
@@ -24,6 +24,7 @@ type Event struct {
Output string `json:"output,omitempty"` Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Note string `json:"note,omitempty"` Note string `json:"note,omitempty"`
Prob any `json:"probs,omitempty"` // question probabilities, when a call returns them (calibration data)
} }
type FlightLog struct { type FlightLog struct {
+16
View File
@@ -116,6 +116,22 @@ func (h *Hub) Publish(listID, name string, fields map[string]any) {
} }
} }
// OpenNest lazily opens a stream for a nest's conversation. Nest ids are
// slugs and list ids are hex, so the same map serves both without collision.
func (h *Hub) OpenNest(nestID string) {
h.mu.Lock()
defer h.mu.Unlock()
if h.lists[nestID] == nil {
h.lists[nestID] = &listStream{created: time.Now(), subs: map[chan sseMsg]struct{}{}}
}
}
// PublishNest records one nest event and sends it to subscribers.
func (h *Hub) PublishNest(nestID, name string, fields map[string]any) {
h.OpenNest(nestID)
h.Publish(nestID, name, fields)
}
// Subscribe returns the history after lastID and a channel for what comes next. // Subscribe returns the history after lastID and a channel for what comes next.
// The channel closes if the subscriber falls too far behind. // The channel closes if the subscriber falls too far behind.
func (h *Hub) Subscribe(listID string, lastID int) ([]sseMsg, chan sseMsg, bool) { func (h *Hub) Subscribe(listID string, lastID int) ([]sseMsg, chan sseMsg, bool) {
+62
View File
@@ -159,6 +159,68 @@ func (c JevCall) confident(threshold float64) bool {
return c.ModeConf >= threshold && c.TaskTypeConf >= threshold return c.ModeConf >= threshold && c.TaskTypeConf >= threshold
} }
// kindQuestions tags a nest message with its kind (A1: Jev first). The escape
// option "say" keeps Jev from forcing a wrong label: any message it isn't sure
// about falls back to the safest kind.
var kindQuestions = map[string]any{
"kind": map[string]any{
"type": "choice",
"instructions": "What kind of chatroom message is this?",
"criteria": map[string]string{
"request": "Asks a member or the room for work.",
"claim": "Takes ownership of a request.",
"deliver": "Posts the result of a request.",
"refer": "Points to a file or artifact instead of pasting it.",
"say": "Ordinary talk, or unclear.",
},
},
}
// ClassifyKind returns the kind when Jev is set and its choice sits at or
// above the confidence band, with the choice probabilities for the log. Not ok
// means the caller falls back to the poster's header (EVENTS.md tagging rule).
func (j *JevClient) ClassifyKind(ctx context.Context, text string) (string, map[string]float64, bool) {
if j == nil {
return "", nil, false
}
answers, _, _, err := j.SystemOne(ctx, text, kindQuestions)
if err != nil {
return "", nil, false
}
a := answers["kind"]
if a.Choice == "" || a.Confidence == nil || *a.Confidence < j.threshold {
return "", nil, false
}
return a.Choice, a.Probabilities, true
}
// ClassifyKind tags a nest message under the same guardrails as any call:
// flock cap, timeout and flight log, with the probabilities recorded.
func (f *Flock) ClassifyKind(ctx context.Context, text string, budgets ...*Budget) (string, map[string]float64, bool) {
if f.jev == nil {
return "", nil, false
}
for _, b := range budgets {
if err := b.Check(); err != nil {
return "", nil, false
}
}
f.sem <- struct{}{}
defer func() { <-f.sem }()
cctx, cancel := context.WithTimeout(ctx, f.g.CrowTimeout)
defer cancel()
start := time.Now()
kind, probs, ok := f.jev.ClassifyKind(cctx, text)
e := Event{Kind: "tag", Item: text, Crow: "Sentry", Model: f.jev.model, Ms: time.Since(start).Milliseconds(), Prob: probs, Note: kind}
if !ok {
e.Note = "" // unsure: the header decides
}
if f.log != nil {
f.log.Write(e)
}
return kind, probs, ok
}
func (c JevCall) summary() string { func (c JevCall) summary() string {
return fmt.Sprintf("Jev: %s %.0f%%, %s %.0f%%", c.Mode, c.ModeConf*100, c.TaskType, c.TaskTypeConf*100) return fmt.Sprintf("Jev: %s %.0f%%, %s %.0f%%", c.Mode, c.ModeConf*100, c.TaskType, c.TaskTypeConf*100)
} }
+184
View File
@@ -0,0 +1,184 @@
package main
import (
"fmt"
"net/http"
"slices"
"strings"
"time"
"unicode/utf8"
)
var crowPostable = map[string]bool{
"say": true,
"request": true,
"claim": true,
"deliver": true,
"refer": true,
}
func (s *Server) getNestMessages(w http.ResponseWriter, r *http.Request) {
nestID := r.PathValue("nest_id")
if _, found := s.nests.Get(nestID); !found {
httpError(w, http.StatusNotFound, fmt.Sprintf("There is no nest called %q.", nestID))
return
}
all := s.msgs.All(nestID)
out := make([]map[string]any, 0, len(all))
for _, m := range all {
out = append(out, map[string]any{
"msg_id": m.MsgID(),
"kind": m.Kind,
"author": m.Author,
"text": m.Text,
"to": m.To,
"refers_to": m.RefersTo,
"tagged_by": m.TaggedBy,
"ts": m.Timestamp,
})
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
nestID := r.PathValue("nest_id")
nest, found := s.nests.Get(nestID)
if !found {
httpError(w, http.StatusNotFound, fmt.Sprintf("There is no nest called %q.", nestID))
return
}
var body struct {
Author string `json:"author"`
Text string `json:"text"`
Kind string `json:"kind"`
To string `json:"to"`
RefersTo string `json:"refers_to"`
}
if !decode(w, r, &body) {
return
}
body.Text = strings.TrimSpace(body.Text)
body.To = strings.TrimSpace(body.To)
// Guardrails (EVENTS.md: Nest messages → Guardrails)
if n := utf8.RuneCountInString(body.Text); n < 1 || n > s.p.G.MaxItemChars {
httpError(w, http.StatusBadRequest, fmt.Sprintf("A message is 1 to %d characters.", s.p.G.MaxItemChars))
return
}
if body.Author == "owner" {
// owner can always post
} else if !slices.Contains(nest.Crows, body.Author) {
httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a member of %s.", body.Author, nestID))
return
}
if body.Author != "owner" {
if ok, msg, wait := s.p.Ledger.Admit(body.Author, time.Now(), s.p.G); !ok {
cooldown := int(wait.Seconds())
writeJSON(w, http.StatusTooManyRequests, map[string]any{"message": msg, "cooldown_s": cooldown})
return
}
}
if body.Kind != "" && body.Author != "owner" && !crowPostable[body.Kind] {
httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a kind crows may post.", body.Kind))
return
}
// Tagging (A1, EVENTS.md): Jev first, header fallback, say as last resort.
kind, taggedBy := s.tagKind(body.Kind, body.Text)
msg := Message{
NestID: nestID,
Kind: kind,
Author: body.Author,
Text: body.Text,
To: body.To,
RefersTo: body.RefersTo,
TaggedBy: taggedBy,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
record, err := s.msgs.Append(msg)
if err != nil {
httpError(w, http.StatusInternalServerError, "could not save the message: "+err.Error())
return
}
s.emitNestMessage(record)
// Event-driven trigger: if kind is request and To is a crow, respond.
if kind == "request" && record.To != "" && record.To != "owner" {
go s.crowResponds(nest, record)
}
writeJSON(w, http.StatusCreated, map[string]any{
"msg_id": record.MsgID(),
"kind": record.Kind,
"author": record.Author,
"text": record.Text,
"to": record.To,
"refers_to": record.RefersTo,
"tagged_by": record.TaggedBy,
"ts": record.Timestamp,
})
}
// tagKind runs the A1 rule: Jev first; Jev unsure -> the poster's header;
// neither -> say. tagged_by records which source won, and a Jev override of a
// header logs as a disagreement (fuel for calibration).
func (s *Server) tagKind(header, text string) (string, string) {
kind, _, ok := s.p.Flock.ClassifyKind(s.ctx, text)
if ok {
if crowPostable[header] && header != kind {
return kind, "disagreement"
}
return kind, "jev"
}
if crowPostable[header] {
return header, "header"
}
return "say", "default"
}
func (s *Server) emitNestMessage(m Message) {
s.hub.PublishNest(m.NestID, "nest.message", map[string]any{
"nest_id": m.NestID,
"msg_id": m.MsgID(),
"kind": m.Kind,
"author": m.Author,
"text": m.Text,
"to": m.To,
"refers_to": m.RefersTo,
"tagged_by": m.TaggedBy,
})
}
func (s *Server) crowResponds(nest Nest, req Message) {
crows := s.p.Flock.pick(nest, 1)
if len(crows) == 0 {
return
}
crow := crows[0] // B4 (@-addressing) makes this the addressed crow
system := systemFor(crow, crowSystem, true)
prompt := "NEST: " + nest.Name + "\nREQUEST FROM: " + req.Author + "\nTEXT: " + req.Text
budget := &Budget{name: "nest-message", limit: s.p.G.TokenBudgetPerItem}
answer, err := s.p.Flock.ask(s.ctx, nil, crow, system, prompt, budget)
if err != nil {
return
}
reply := Message{
NestID: nest.ID,
Kind: "say",
Author: crow,
Text: strings.TrimSpace(answer),
To: req.Author,
RefersTo: req.MsgID(),
TaggedBy: "harness",
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
record, err := s.msgs.Append(reply)
if err != nil {
return
}
s.emitNestMessage(record)
}
+18 -1
View File
@@ -32,6 +32,7 @@ type Server struct {
approver *WebApprover approver *WebApprover
hub *Hub hub *Hub
nests *NestStore nests *NestStore
msgs *MessageStore
ctx context.Context // cancelled on shutdown; lists and streams end with it ctx context.Context // cancelled on shutdown; lists and streams end with it
beat time.Duration beat time.Duration
} }
@@ -40,6 +41,9 @@ func NewServer(ctx context.Context, p *Pipeline, approver *WebApprover, nests *N
return &Server{p: p, approver: approver, hub: NewHub(), nests: nests, ctx: ctx, beat: heartbeat} return &Server{p: p, approver: approver, hub: NewHub(), nests: nests, ctx: ctx, beat: heartbeat}
} }
// WithMessages attaches the nest conversation log (chatroom path).
func (s *Server) WithMessages(m *MessageStore) *Server { s.msgs = m; return s }
func (s *Server) Handler() http.Handler { func (s *Server) Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("POST /api/lists", s.postList) mux.HandleFunc("POST /api/lists", s.postList)
@@ -49,6 +53,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/crows", s.crows) mux.HandleFunc("GET /api/crows", s.crows)
mux.HandleFunc("GET /api/nests", s.getNests) mux.HandleFunc("GET /api/nests", s.getNests)
mux.HandleFunc("POST /api/nests", s.postNest) mux.HandleFunc("POST /api/nests", s.postNest)
mux.HandleFunc("GET /api/nests/{nest_id}/messages", s.getNestMessages)
mux.HandleFunc("POST /api/nests/{nest_id}/messages", s.postNestMessage)
if st, err := os.Stat(webDir); err == nil && st.IsDir() { if st, err := os.Stat(webDir); err == nil && st.IsDir() {
mux.Handle("GET /", http.FileServer(http.Dir(webDir))) mux.Handle("GET /", http.FileServer(http.Dir(webDir)))
} else { } else {
@@ -73,6 +79,8 @@ func cmdServe(args []string) {
defer flog.Close() defer flog.Close()
nests, err := LoadNests(filepath.Join(dataDir, "nests.json")) nests, err := LoadNests(filepath.Join(dataDir, "nests.json"))
must(err) must(err)
msgs, err := LoadMessageStore(filepath.Join(dataDir, "messages"))
must(err)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop() defer stop()
@@ -84,7 +92,7 @@ func cmdServe(args []string) {
if jev != nil { if jev != nil {
fmt.Printf("Sentry triage: %s, LLM fallback\n", jev.Name()) fmt.Printf("Sentry triage: %s, LLM fallback\n", jev.Name())
} }
s := NewServer(ctx, p, approver, nests) s := NewServer(ctx, p, approver, nests).WithMessages(msgs)
srv := &http.Server{Addr: *addr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second} srv := &http.Server{Addr: *addr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second}
go func() { go func() {
@@ -190,6 +198,15 @@ func newListID() string {
func (s *Server) events(w http.ResponseWriter, r *http.Request) { func (s *Server) events(w http.ResponseWriter, r *http.Request) {
listID := r.URL.Query().Get("list_id") listID := r.URL.Query().Get("list_id")
nestID := r.URL.Query().Get("nest_id")
if listID == "" && nestID != "" {
if _, found := s.nests.Get(nestID); !found {
httpError(w, http.StatusNotFound, "no such nest_id")
return
}
s.hub.OpenNest(nestID)
listID = nestID
}
lastID, _ := strconv.Atoi(r.Header.Get("Last-Event-ID")) lastID, _ := strconv.Atoi(r.Header.Get("Last-Event-ID"))
past, ch, ok := s.hub.Subscribe(listID, lastID) past, ch, ok := s.hub.Subscribe(listID, lastID)
if !ok { if !ok {