457 lines
14 KiB
Go
457 lines
14 KiB
Go
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,
|
|
"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.`
|
|
|
|
// serviceRoles are addressable in a nest but never members: they only act when
|
|
// a crow's or the owner's message asks them to.
|
|
var serviceRoles = []string{"Elder", "Quill"}
|
|
|
|
// nestMembers lists who may be addressed in a nest: its crows, the owner, and
|
|
// the service roles.
|
|
func nestMembers(nest Nest) []string {
|
|
return append(append(append([]string{}, nest.Crows...), "owner"), serviceRoles...)
|
|
}
|
|
|
|
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, nestRecord(m))
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// nestRecord is the EVENTS.md wire shape for one message.
|
|
func nestRecord(m Message) map[string]any {
|
|
rec := 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,
|
|
}
|
|
if m.Mode != "" {
|
|
rec["mode"] = m.Mode
|
|
rec["mode_tagged_by"] = m.ModeTagged
|
|
}
|
|
if m.Invited > 0 {
|
|
rec["invited"] = m.Invited
|
|
}
|
|
return rec
|
|
}
|
|
|
|
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"`
|
|
Mode string `json:"mode"`
|
|
Invited int `json:"invited"`
|
|
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)
|
|
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 {
|
|
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)
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Mode (Epic C, same rule shape as kind): Jev first, header escape, "" when unsure.
|
|
mode, modeBy := s.tagMode(kind, body.Mode, body.Text)
|
|
|
|
// A conspiracy expects several crows on the one task; the invite count is
|
|
// capped by MurderSize and the flock's in-flight limit.
|
|
invited := 0
|
|
if mode == "conspiracy" {
|
|
invited = s.inviteCount(nest, body.Invited)
|
|
}
|
|
|
|
// Claim arbitration (#5, Epic C): first claim wins unless the referent is a
|
|
// conspiracy, which expects multiple claims. Directed assignment holds:
|
|
// while the asked crow has not answered, only it may claim.
|
|
if kind == "claim" && body.RefersTo != "" {
|
|
if origin, found := s.msgs.ByMsgID(body.RefersTo); found && origin.Mode != "conspiracy" {
|
|
if slices.Contains(nest.Crows, origin.To) && origin.To != body.Author && !s.msgs.Answered(origin.NestID, origin.MsgID(), origin.To) {
|
|
httpError(w, http.StatusConflict, origin.To+" was asked to take this; it answers or declines first.")
|
|
return
|
|
}
|
|
if owner, claimed := s.msgs.FirstClaim(origin.NestID, origin.MsgID()); claimed {
|
|
httpError(w, http.StatusConflict, origin.MsgID()+" is claimed by "+owner+"; review it instead of redoing it.")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
msg := Message{
|
|
NestID: nestID,
|
|
Kind: kind,
|
|
Author: body.Author,
|
|
Text: body.Text,
|
|
To: body.To,
|
|
RefersTo: body.RefersTo,
|
|
TaggedBy: taggedBy,
|
|
Mode: mode,
|
|
ModeTagged: modeBy,
|
|
Invited: invited,
|
|
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 (no idle loops):
|
|
// conspiracy -> several invited crows, each from its own lens
|
|
// any other request addressed to a member crow -> one must-respond reply
|
|
if kind == "request" {
|
|
switch {
|
|
case mode == "conspiracy" && invited > 0:
|
|
go s.respondConspiracy(nest, record, invited)
|
|
case record.To == "Elder":
|
|
go s.respondElder(nest, record)
|
|
case record.To == "Quill":
|
|
go s.respondQuill(nest, record)
|
|
case slices.Contains(nest.Crows, record.To):
|
|
go s.respondToRequest(nest, record)
|
|
default:
|
|
go s.respondOpenRequest(nest, record)
|
|
}
|
|
}
|
|
|
|
if kind == "deliver" {
|
|
go s.checkDivergence(nest, record)
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, nestRecord(record))
|
|
}
|
|
|
|
var modePostable = map[string]bool{"solo": true, "conspiracy": true, "split": true}
|
|
|
|
// tagMode classifies a request's mode (Jev first, header escape). Only requests
|
|
// carry a mode; other kinds get "".
|
|
func (s *Server) tagMode(kind, header, text string) (string, string) {
|
|
if kind != "request" {
|
|
return "", ""
|
|
}
|
|
if m, ok := s.p.Flock.ClassifyMode(s.ctx, text); ok {
|
|
if modePostable[header] && header != m {
|
|
return m, "disagreement"
|
|
}
|
|
return m, "jev"
|
|
}
|
|
if modePostable[header] {
|
|
return header, "header"
|
|
}
|
|
return "", "default"
|
|
}
|
|
|
|
// 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) {
|
|
rec := nestRecord(m)
|
|
rec["nest_id"] = m.NestID
|
|
s.hub.PublishNest(m.NestID, "nest.message", rec)
|
|
}
|
|
|
|
// inviteCount is how many crows a conspiracy asks to take the one task, capped
|
|
// by MurderSize, the flock's in-flight limit, and the nest's own size.
|
|
func (s *Server) inviteCount(nest Nest, requested int) int {
|
|
cap := s.p.G.MurderSize
|
|
if s.p.G.MaxCrowsInFlight < cap {
|
|
cap = s.p.G.MaxCrowsInFlight
|
|
}
|
|
if requested > 0 && requested < cap {
|
|
cap = requested
|
|
}
|
|
if len(nest.Crows) < cap {
|
|
cap = len(nest.Crows)
|
|
}
|
|
return cap
|
|
}
|
|
|
|
// respondConspiracy invites N eligible crows; each answers from its own
|
|
// persona lens so distinct strengths produce distinct readings. Strangers are
|
|
// skipped: unprompted speech is earned (trust ladder, section 10).
|
|
func (s *Server) respondConspiracy(nest Nest, req Message, invited int) {
|
|
n := 0
|
|
for _, crow := range nest.Crows {
|
|
if n >= invited {
|
|
break
|
|
}
|
|
if !s.maySpeakUnprompted(crow) {
|
|
continue
|
|
}
|
|
n++
|
|
go s.respondOne(nest, req, crow, true)
|
|
}
|
|
}
|
|
|
|
// respondOpenRequest surfaces an unaddressed request to one regular-and-up
|
|
// crow as a claim opportunity (section 5: a regular may claim open requests).
|
|
func (s *Server) respondOpenRequest(nest Nest, req Message) {
|
|
for _, crow := range nest.Crows {
|
|
if s.maySpeakUnprompted(crow) {
|
|
go s.respondOne(nest, req, crow, false)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// maySpeakUnprompted is the trust gate for open (unaddressed) work.
|
|
func (s *Server) maySpeakUnprompted(crow string) bool {
|
|
return s.p.Ledger.Level(crow, "other") > Stranger
|
|
}
|
|
|
|
// conspiracyLine is the lens directive added to every invited crow's prompt.
|
|
const conspiracyLine = "You are one lens among several on this request; answer from your own strength, not the flock's consensus."
|
|
|
|
func (s *Server) respondToRequest(nest Nest, req Message) {
|
|
s.respondOne(nest, req, req.To, false)
|
|
}
|
|
|
|
// respondOne is the must-respond rule for a single crow: answer with claim,
|
|
// deliver or decline. A non-responsive reply is retried once with a stricter
|
|
// ask; still nothing responsive and the crow gets a Sentry flag (no reply).
|
|
func (s *Server) respondOne(nest Nest, req Message, crow string, lens bool) {
|
|
budget := &Budget{name: "nest-reply", limit: s.p.G.TokenBudgetPerItem}
|
|
system := systemFor(crow, nestReplySystem, false)
|
|
prompt := "NEST: " + nest.Name + "\nREQUEST FROM: " + req.Author + "\nRespond to " + req.Author + ".\nTASK: " + req.Text
|
|
if lens {
|
|
strength := ""
|
|
if p, ok := personaOf(crow); ok {
|
|
strength = p.Strength
|
|
}
|
|
prompt += "\n" + conspiracyLine + "\nYour strength: " + strength + " (" + crow + ")."
|
|
}
|
|
|
|
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: kind,
|
|
Author: crow,
|
|
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)
|
|
if record.Kind == "deliver" {
|
|
s.checkDivergence(nest, record)
|
|
}
|
|
}
|
|
}
|
|
|
|
// respondElder merges the reports on a thread: the Elder only speaks when a
|
|
// message asks it to (CHATROOM_SPEC section 6 — synthesis is requested, not a
|
|
// fixed step). It answers whoever opened the thread it merges.
|
|
func (s *Server) respondElder(nest Nest, req Message) {
|
|
if req.RefersTo == "" {
|
|
return
|
|
}
|
|
target, found := s.msgs.ByMsgID(req.RefersTo)
|
|
if !found {
|
|
return
|
|
}
|
|
var reports []string
|
|
for _, m := range s.msgs.ReportsTo(nest.ID, target.MsgID(), "deliver") {
|
|
if slices.Contains(nest.Crows, m.Author) {
|
|
reports = append(reports, "["+m.Author+"] "+m.Text)
|
|
}
|
|
}
|
|
if len(reports) == 0 {
|
|
return
|
|
}
|
|
budget := &Budget{name: "elder-merge", limit: s.p.G.TokenBudgetPerItem}
|
|
system := systemFor("Elder", elderSystem, true)
|
|
prompt := "REQUEST: " + target.Text + "\n--- reports to merge ---\n" + strings.Join(reports, "\n--- \n")
|
|
answer, err := s.p.Flock.ask(s.ctx, nil, "Elder", system, prompt, budget)
|
|
if err != nil {
|
|
return
|
|
}
|
|
reply := Message{
|
|
NestID: nest.ID,
|
|
Kind: "deliver",
|
|
Author: "Elder",
|
|
Text: strings.TrimSpace(answer),
|
|
To: target.Author,
|
|
RefersTo: target.MsgID(),
|
|
TaggedBy: "harness",
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
if record, err := s.msgs.Append(reply); err == nil {
|
|
s.emitNestMessage(record)
|
|
}
|
|
}
|
|
|
|
// checkDivergence asks Jev whether this deliver restates earlier delivers that
|
|
// answer the same request. Above the band the crow gets a review nudge; the
|
|
// deliver itself stays in the log (nudges never silence).
|
|
func (s *Server) checkDivergence(nest Nest, m Message, budgets ...*Budget) {
|
|
if m.Kind != "deliver" {
|
|
return
|
|
}
|
|
earlier := s.msgs.EarlierDelivers(nest.ID, m.RefersTo, m.ID)
|
|
if len(earlier) == 0 {
|
|
return
|
|
}
|
|
if _, nudged := s.msgs.NudgeFor(nest.ID, m.MsgID()); nudged {
|
|
return // one nudge per deliver; no loop
|
|
}
|
|
prob, ok := s.p.Flock.ClassifyDivergence(s.ctx, map[string]any{"this": m.Text, "earlier": earlier}, budgets...)
|
|
if !ok || prob < s.p.Flock.jev.threshold {
|
|
return
|
|
}
|
|
nudge := Message{
|
|
NestID: nest.ID,
|
|
Kind: "nudge",
|
|
Author: "harness",
|
|
Text: "This reads like a point already made in the thread; review the earlier delivers and add your distinct angle or let it rest.",
|
|
To: m.Author,
|
|
RefersTo: m.MsgID(),
|
|
TaggedBy: "harness",
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
if record, err := s.msgs.Append(nudge); 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),
|
|
}
|
|
if record, err := s.msgs.Append(flag); err == nil {
|
|
s.emitNestMessage(record)
|
|
}
|
|
}
|