diff --git a/EVENTS.md b/EVENTS.md index e478ad6..2159c31 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -41,6 +41,7 @@ A nest is a conversation. Members (the owner and crows) post messages of a **kin | `claim` | a crow | Takes ownership of a `request`. solo/split: first claim wins. **conspiracy:** multiple claims are expected, each a distinct lens. | | `deliver` | a crow | The result, addressed back to whoever requested. | | `refer` | any member | Points to an artifact by name instead of pasting it. | +| `decline` | a crow | Refuses a request with a reason. Counts as responding. | | `nudge` | harness only | A Jev verdict handed to one crow to proceed on a missed expectation. Not a chat message from a crow. | | `flag` | Sentry only | A suspected contract breach. Quarantines the crow and opens an `ask_owner`. | @@ -81,7 +82,7 @@ Jev choice-tags every posted message (`kind` question). Above its high band, Jev - `text` 1–2,000 characters after trimming (same cap as a list item). - `author` must be `"owner"` or a member of the nest; a non-member posts through the assistant or a member (room membership is a boundary). -- `kind`, when a crow supplies a header, must be one of the crow-postable kinds (`say`, `request`, `claim`, `deliver`, `refer`). Crows may not post `nudge` or `flag`. +- `kind`, when a crow supplies a header, must be one of the crow-postable kinds (`say`, `request`, `claim`, `deliver`, `refer`, `decline`). Crows may not post `nudge` or `flag`. - Requests per minute and cooldown reuse the requester strikes in the ledger, keyed by `author`. ## Settled details diff --git a/addressing.go b/addressing.go new file mode 100644 index 0000000..e483ba8 --- /dev/null +++ b/addressing.go @@ -0,0 +1,41 @@ +package main + +import ( + "strings" + "unicode" +) + +// responsive kinds: how an addressed crow may answer a request (must-respond). +var responsiveKind = map[string]bool{"claim": true, "deliver": true, "decline": true} + +// parseAddressing finds the first @handle in text that names one of members. +// An unknown handle stays literal text and yields "" (no address). +func parseAddressing(text string, members []string) string { + for _, tok := range strings.Fields(text) { + if !strings.HasPrefix(tok, "@") { + continue + } + name := strings.TrimFunc(tok[1:], func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) + for _, m := range members { + if strings.EqualFold(name, m) { + return m + } + } + } + return "" +} + +// splitKind pulls a leading "KIND: x" line off a crow's raw reply and returns +// the lowercased kind word ("" if absent) and the remaining body. +func splitKind(text string) (kind, body string) { + trimmed := strings.TrimLeft(text, " \t\n") + i := strings.IndexByte(trimmed, '\n') + first, rest := trimmed, "" + if i >= 0 { + first, rest = trimmed[:i], strings.TrimSpace(trimmed[i+1:]) + } + if len(first) >= 5 && strings.EqualFold(first[:5], "KIND:") { + return strings.ToLower(strings.TrimSpace(first[5:])), rest + } + return "", text +} diff --git a/brain.go b/brain.go index 0239ab3..8053e70 100644 --- a/brain.go +++ b/brain.go @@ -233,6 +233,8 @@ func (MockBrain) Ask(ctx context.Context, system, prompt string) (string, int, e switch { case strings.Contains(system, markerBanter): return "[mock] Caw.", tokens, nil + case strings.Contains(system, markerNestReply): + return "KIND: deliver\n[mock] handled: " + after(prompt, "TASK:"), tokens, nil case strings.Contains(system, markerSentry): return mockTriage(after(prompt, "ITEM:")), tokens, nil case strings.Contains(system, markerElder): diff --git a/nestapi.go b/nestapi.go index 07658cd..d1a8b22 100644 --- a/nestapi.go +++ b/nestapi.go @@ -15,6 +15,22 @@ var crowPostable = map[string]bool{ "claim": true, "deliver": true, "refer": true, + "decline": true, +} + +// nestReplySystem tells an addressed crow the response contract: its reply +// must open with a KIND line the harness reads (must-respond). +const markerNestReply = "This is a chatroom request." +const nestReplySystem = crowSystem + ` +` + markerNestReply + ` Start your reply on its own first line with exactly: +KIND: claim (you will do it) +KIND: deliver (here is the result) +KIND: decline (with a reason) +Then, on the next line, your message text.` + +// nestMembers lists who may be addressed in a nest: its crows plus the owner. +func nestMembers(nest Nest) []string { + return append(append([]string{}, nest.Crows...), "owner") } func (s *Server) getNestMessages(w http.ResponseWriter, r *http.Request) { @@ -61,6 +77,9 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { body.Text = strings.TrimSpace(body.Text) body.To = strings.TrimSpace(body.To) + if body.To == "" { + body.To = parseAddressing(body.Text, nestMembers(nest)) + } // Guardrails (EVENTS.md: Nest messages → Guardrails) if n := utf8.RuneCountInString(body.Text); n < 1 || n > s.p.G.MaxItemChars { @@ -88,6 +107,14 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { // Tagging (A1, EVENTS.md): Jev first, header fallback, say as last resort. kind, taggedBy := s.tagKind(body.Kind, body.Text) + // claim/deliver/decline answer a request: with no explicit address they go + // back to whoever posted the message they refer to (EVENTS.md deliver rule). + if body.To == "" && (kind == "claim" || kind == "deliver" || kind == "decline") && body.RefersTo != "" { + if origin, found := s.msgs.ByMsgID(body.RefersTo); found { + body.To = origin.Author + } + } + msg := Message{ NestID: nestID, Kind: kind, @@ -106,9 +133,10 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { 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) + // Event-driven trigger: a request addressed to a member crow gets one + // response (must-respond) or a Sentry flag. + if kind == "request" && slices.Contains(nest.Crows, record.To) { + go s.respondToRequest(nest, record) } writeJSON(w, http.StatusCreated, map[string]any{ @@ -153,32 +181,68 @@ func (s *Server) emitNestMessage(m Message) { }) } -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 +// respondToRequest is the must-respond rule: the addressed crow answers with +// claim, deliver or decline. A reply that isn't one of those is retried once +// with a stricter ask; still nothing responsive and the crow gets a flag from +// the Sentry (no crow reply is posted). +func (s *Server) respondToRequest(nest Nest, req Message) { + crow := req.To 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 { + system := systemFor(crow, nestReplySystem, false) + prompt := "NEST: " + nest.Name + "\nREQUEST FROM: " + req.Author + "\nTASK: " + req.Text + "\nRespond to " + req.Author + "." + + kind, body, ok := s.nestReply(system, prompt, crow, budget) + if !ok { + nudge := prompt + "\nYour last reply did not claim, deliver or decline this request. Answer with a KIND line now." + kind, body, ok = s.nestReply(system, nudge, crow, budget) + } + if !ok { + s.postFlag(nest, crow, req) return } reply := Message{ NestID: nest.ID, - Kind: "say", + Kind: kind, Author: crow, - Text: strings.TrimSpace(answer), - To: req.Author, + Text: body, + To: req.Author, // responses go back to whoever requested + RefersTo: req.MsgID(), + TaggedBy: "header", + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + if record, err := s.msgs.Append(reply); err == nil { + s.emitNestMessage(record) + } +} + +// nestReply asks one crow once and reads its KIND header. ok means the reply +// was responsive (claim, deliver or decline). +func (s *Server) nestReply(system, prompt, crow string, budget *Budget) (kind, body string, ok bool) { + answer, err := s.p.Flock.ask(s.ctx, nil, crow, system, prompt, budget) + if err != nil { + return "", "", false + } + kind, body = splitKind(answer) + if !responsiveKind[kind] || strings.TrimSpace(body) == "" { + return "", "", false + } + return kind, strings.TrimSpace(body), true +} + +// postFlag records the Sentry's breach note: an addressed crow that would not +// answer. The quarantine/ask_owner loop lands with the watchdog epic. +func (s *Server) postFlag(nest Nest, crow string, req Message) { + flag := Message{ + NestID: nest.ID, + Kind: "flag", + Author: "Sentry", + Text: fmt.Sprintf("%s ignored a request from %s and did not answer after one retry. Flagged for contract breach.", crow, req.Author), + To: crow, RefersTo: req.MsgID(), TaggedBy: "harness", Timestamp: time.Now().UTC().Format(time.RFC3339), } - record, err := s.msgs.Append(reply) - if err != nil { - return + if record, err := s.msgs.Append(flag); err == nil { + s.emitNestMessage(record) } - s.emitNestMessage(record) } diff --git a/nestlog.go b/nestlog.go index 17f4bd1..ec892b4 100644 --- a/nestlog.go +++ b/nestlog.go @@ -102,6 +102,20 @@ func (s *MessageStore) After(nestID string, lastID int) []Message { // All returns every message in a nest, oldest first. func (s *MessageStore) All(nestID string) []Message { return s.After(nestID, 0) } +// ByMsgID finds one stored message by its "-" id. +func (s *MessageStore) ByMsgID(msgID string) (Message, bool) { + s.mu.Lock() + defer s.mu.Unlock() + for _, msgs := range s.log { + for _, m := range msgs { + if m.MsgID() == msgID { + return m, true + } + } + } + return Message{}, false +} + func (s *MessageStore) save(nestID string) error { if err := os.MkdirAll(s.dir, 0o755); err != nil { return err