The operator would not read the Telegram ask for ADR 0343: thirty plain lines, every clause on one, no bold, no summary. A settings proposal was the same wall of text, its values masked so the approval was blind. - messenger: an ask's message is the asker's headline and explanation alone; who asked and when, what each answer does, the expiry, where else it is answered and the asker's Details travel as the message's note (asks.Message.Note). Answered, the message is one line, "You chose Approve at 16:06 on Telegram · <headline>". An explanation that does not read at a glance (asks.Glance) is shown and logged. On a kind that proves who answers, an ask's values are shown whole (asks.Ask.Whole), held to the secret rule alone (from the earlier work on issue 383, #178). - telegram: every message in HTML, never reworded: the title bold, a key line monospace, the note apart in a collapsed blockquote; markup escaped. A tap is compared by its words, whitespace aside, and a message shown before is still compared by the digest it was kept under. - desk-channel: the note follows the body. - mesh-knowledge: a record that needs the operator carries a brief (headline, summary, at most three points), the 30th per-record rule; its ask shows the brief and never the clauses. An ask made before is composed again as it was asked (form), so its warrant still holds. - claude-code: an instructions proposal's explanation is one summary line (the proposer's, or the module's), its text in the Details; a question's context is at most 300 characters and its recommendation one point. Older proposals and questions keep their form. The SDK is pinned at mesh-sdk 9588871 (fix/639-an-ask-reads-at-a-glance), which adds Message.Note and asks.Glance.
336 lines
14 KiB
Go
336 lines
14 KiB
Go
package main
|
|
|
|
// A record that needs the operator (novox/hq ADR 0290 rule 4, to-be 55 §5): once the knowledge base's own review
|
|
// approves it, the operator is asked on the router with Approve and Decline, both at the level approve, so only a channel
|
|
// that proves who answered can carry either (ADR 0259, as claude-code's proposals ask it, ADR 0274). Each option
|
|
// binds the act it authorises: this record, this version's digest. The knowledge base accepts the record only on
|
|
// the router's warrant for Approve, read back from the router's own record of the ask (which only the router
|
|
// writes) and held to the ask composed again from the record: the same digest, an option it offered, at its
|
|
// level, in time, and the act the option bound. A record changed after it was asked finds the digests differ, and
|
|
// nothing is accepted. The warrant is kept with the record.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
)
|
|
|
|
// Module is this module's name: the one the bus grants its asks under.
|
|
const Module = "mesh-knowledge"
|
|
|
|
// The two answers, both at the level approve.
|
|
const (
|
|
OptionApprove = "approve"
|
|
OptionDecline = "decline"
|
|
)
|
|
|
|
// AskLasts is how long the operator has to answer: a day less a margin, under the router's bound for an approval.
|
|
const AskLasts = asks.ApproveLasts - 10*time.Minute
|
|
|
|
// The states of an operator's ask, as the record keeps them.
|
|
const (
|
|
AskWaiting = "waiting for the operator's answer"
|
|
AskApproved = "approved by the operator"
|
|
AskDeclined = "declined by the operator"
|
|
)
|
|
|
|
// AskBus is the operator channel as this module reaches it: publish an ask under its own name, and read the
|
|
// router's record of one of its asks.
|
|
type AskBus interface {
|
|
Publish(subject string, body any, id string) error
|
|
Record(id string) (*stdio.StateEntry, error)
|
|
}
|
|
|
|
type sdkAskBus struct{}
|
|
|
|
func (sdkAskBus) Publish(subject string, body any, id string) error {
|
|
return stdio.SeatPublish(subject, body, id)
|
|
}
|
|
func (sdkAskBus) Record(id string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", id) }
|
|
|
|
// act is what an answer binds: this record's version, by its digest, and the answer.
|
|
func act(r *Record, a *OperatorAsk, option string) asks.Act {
|
|
return asks.Act{"verb": "knowledge-base.accept", "option": option, "ask": a.ID, "kind": r.Kind,
|
|
"number": strconv.Itoa(r.Number), "version": strconv.Itoa(a.Version), "digest": r.Digest()}
|
|
}
|
|
|
|
var (
|
|
unshownPath = regexp.MustCompile(`[/\\@~$]|\d+\.\d+\.\d+|[A-Za-z0-9+/=_]{24,}|(?i)\b[a-z0-9-]+\.[a-z]{2,12}\b`)
|
|
)
|
|
|
|
// shown is a line the operator's phone may be shown: no path, address, host-like name or long unbroken word —
|
|
// narrower than the router's own rule, so the ask is not refused for its words.
|
|
func shown(line string) bool { return !unshownPath.MatchString(line) }
|
|
|
|
// fingerprint is a digest as the operator is shown it: its first 24 hexadecimal digits in groups of four.
|
|
func fingerprint(digest string) string {
|
|
h := strings.TrimPrefix(digest, "sha256:")
|
|
if len(h) < 24 {
|
|
return h
|
|
}
|
|
var g []string
|
|
for i := 0; i < 24; i += 4 {
|
|
g = append(g, h[i:i+4])
|
|
}
|
|
return strings.Join(g, " ")
|
|
}
|
|
|
|
// AskGlance is the form of an ask read at a glance (OperatorAsk.Form, novox/hq issue 639).
|
|
const AskGlance = 1
|
|
|
|
// composeAsk is the ask for a record, composed from the record and its ask alone: asked once, and composed
|
|
// again to hold the warrant to it — in the form it was asked in, so an ask made before a form changed is still
|
|
// held to the words the operator was shown.
|
|
func composeAsk(r *Record, a *OperatorAsk) asks.Ask {
|
|
if a.Form == AskGlance {
|
|
return glanceAsk(r, a)
|
|
}
|
|
return listedAsk(r, a)
|
|
}
|
|
|
|
// glanceAsk is the ask read at a glance on the phone (novox/hq issue 639): the brief's headline, its summary and
|
|
// points; who proposed it, its review, its fingerprint and how to read every clause are its Details, which the
|
|
// router notes after the answers. A record without a brief (one proposed before the rule) is asked with its
|
|
// title as its summary. The clauses are never listed.
|
|
func glanceAsk(r *Record, a *OperatorAsk) asks.Ask {
|
|
name := r.Key().String()
|
|
headline, explanation := "Approve "+name+"?", name+" needs your approval before it holds."
|
|
if t := strings.TrimSuffix(strings.TrimSpace(r.Title), "."); shown(t) && len([]rune(t)) < asks.SummaryLength {
|
|
explanation = t + "."
|
|
}
|
|
if r.Brief != nil && len(briefProblems(r.Brief)) == 0 {
|
|
headline, explanation = strings.TrimSpace(r.Brief.Headline), r.Brief.Explanation()
|
|
}
|
|
author := r.Author
|
|
if author == "" {
|
|
author = "an agent session"
|
|
}
|
|
clauses := fmt.Sprintf("Read its %d clauses with the knowledge base's get, number %d.", len(r.Clauses), r.Number)
|
|
if len(r.Clauses) == 0 {
|
|
clauses = fmt.Sprintf("Read it whole with the knowledge base's get, number %d.", r.Number)
|
|
}
|
|
details := fmt.Sprintf("%s, proposed by %s; the knowledge base's own review approved it. Fingerprint %s.\n%s",
|
|
name, author, fingerprint(r.Digest()), clauses)
|
|
return withOptions(r, a, asks.Ask{ID: a.ID, Headline: headline, Explanation: explanation, Details: details})
|
|
}
|
|
|
|
// listedAsk is the ask as it was composed before the brief: every clause's lead listed. Composed only to hold
|
|
// the warrant of an ask made then.
|
|
func listedAsk(r *Record, a *OperatorAsk) asks.Ask {
|
|
name := r.Key().String()
|
|
headline := "Accept " + name + "?"
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "%s was proposed by %s and approved by the knowledge base's own review. Its author says it needs your approval before it holds.", name, r.Author)
|
|
if shown(r.Title) {
|
|
fmt.Fprintf(&b, "\n\nIts title: %s.", strings.TrimSuffix(r.Title, "."))
|
|
}
|
|
if len(r.Clauses) > 0 {
|
|
fmt.Fprintf(&b, "\n\nIts %d clause(s):", len(r.Clauses))
|
|
size, hidden := 0, 0
|
|
for _, c := range r.Clauses {
|
|
lead := c.Lead()
|
|
if !shown(lead) || size+len(lead) > 1200 {
|
|
hidden++
|
|
continue
|
|
}
|
|
size += len(lead)
|
|
fmt.Fprintf(&b, "\n%d. %s", c.N, lead)
|
|
}
|
|
if hidden > 0 {
|
|
fmt.Fprintf(&b, "\n(%d lead(s) not shown here)", hidden)
|
|
}
|
|
}
|
|
fmt.Fprintf(&b, "\n\nRead it whole with the knowledge base's get, number %d; its fingerprint is %s.", r.Number, fingerprint(r.Digest()))
|
|
return withOptions(r, a, asks.Ask{ID: a.ID, Headline: headline, Explanation: b.String()})
|
|
}
|
|
|
|
// withOptions is an ask with what every form of it carries: who answers, its expiry, what it is about, and its
|
|
// two options, each binding its act.
|
|
func withOptions(r *Record, a *OperatorAsk, out asks.Ask) asks.Ask {
|
|
out.Who, out.Expires = asks.Operator, a.At.Add(AskLasts)
|
|
out.OnExpiry = "the record stays proposed; nothing of it is in force"
|
|
out.About = "knowledge." + r.Kind + "." + strconv.Itoa(r.Number)
|
|
for _, o := range []struct{ id, label, does string }{
|
|
{OptionApprove, "Approve", "the record is accepted and its clauses are in force"},
|
|
{OptionDecline, "Decline", "the record stays proposed; nothing of it is in force"},
|
|
} {
|
|
binds, _ := asks.ActDigest(act(r, a, o.id))
|
|
out.Options = append(out.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func newAskID(r *Record) string {
|
|
var b [4]byte
|
|
_, _ = rand.Read(b[:])
|
|
return fmt.Sprintf("kb-%s-%d-%s", r.Kind, r.Number, hex.EncodeToString(b[:]))
|
|
}
|
|
|
|
// askOperator asks the operator to approve a record the knowledge base's review approved, and keeps the ask with
|
|
// it. The caller holds the write lock. An ask that could not be made or published is taken back, and the review is
|
|
// asked again after a pause, counted with the tries before it (tries): the record stays proposed, and is reviewed
|
|
// at most MostReviewTries times.
|
|
func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string, tries int) (string, error) {
|
|
version := held.CurrentVersion
|
|
if held.Pending != nil {
|
|
version = held.PendingVersion
|
|
}
|
|
unasked := func(why string) (string, error) {
|
|
w.Ask = nil
|
|
w.Judging = failedJudging(tries+1, why, k.now())
|
|
said := fmt.Sprintf("approved by the knowledge base's review, and %s (%s", why, w.Judging.owedSays())
|
|
e, err := k.Store.Get(ctx, w.Key())
|
|
if err != nil || e == nil {
|
|
return "", fmt.Errorf("%s: %s, and that could not be kept (%v)", w.Key(), said, err)
|
|
}
|
|
if err := k.Store.Save(ctx, Version{By: ReviewBy, At: k.now(), Why: said, Record: w, Insight: e.Pending != nil},
|
|
e.Pending == nil, false, e.Revision); err != nil {
|
|
return "", fmt.Errorf("%s: %s, and that could not be kept (%v)", w.Key(), said, err)
|
|
}
|
|
k.emit(EventReviewed, w, ReviewBy, said, e.Versions+1)
|
|
return w.Key().String() + ": " + said, nil
|
|
}
|
|
if k.Asks == nil {
|
|
return unasked("the operator cannot be asked from here")
|
|
}
|
|
oa := &OperatorAsk{ID: newAskID(w), At: k.now(), Version: version, Said: AskWaiting, Form: AskGlance}
|
|
ask := composeAsk(w, oa)
|
|
if err := ask.Check(k.now()); err != nil {
|
|
return unasked(fmt.Sprintf("the ask could not be made (%v)", err))
|
|
}
|
|
w.Ask = oa
|
|
if err := k.Store.Save(ctx, Version{By: by, At: k.now(), Why: "reviewed: approved; the operator is asked", Record: w, Insight: held.Pending != nil},
|
|
held.Pending == nil, false, held.Revision); err != nil {
|
|
return "", fmt.Errorf("%s: the review could not be kept (%v); the operator was not asked", w.Key(), err)
|
|
}
|
|
k.emit(EventReviewed, w, by, "approved; the operator is asked", held.Versions+1)
|
|
if err := k.Asks.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
|
|
k.logf("[mesh-knowledge] the ask for %s could not be published: %v", w.Key(), err)
|
|
return unasked(fmt.Sprintf("the operator could not be asked (%v)", err))
|
|
}
|
|
return fmt.Sprintf("%s needs the operator: asked on a channel that proves who answers (%q). It is accepted only on the operator's Approve; until then nothing of it is in force.", w.Key(), ask.Headline), nil
|
|
}
|
|
|
|
// routerRecord is the router's record of one ask, as far as this module reads it.
|
|
type routerRecord struct {
|
|
State string `json:"state"`
|
|
Ask *asks.Ask `json:"ask"`
|
|
Warrant *asks.Warrant `json:"warrant"`
|
|
}
|
|
|
|
// Reconcile decides every record waiting for the operator whose ask the router has closed: accepted on a warrant
|
|
// for Approve that holds, left proposed on Decline, an expiry or a warrant that does not hold. It answers a line
|
|
// for each decision.
|
|
func (k *KB) Reconcile() ([]string, error) {
|
|
if k.Asks == nil {
|
|
return nil, nil
|
|
}
|
|
ctx := context.Background()
|
|
k.mu.Lock()
|
|
defer k.mu.Unlock()
|
|
all, err := k.Store.All(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var said []string
|
|
for _, e := range all {
|
|
w := e.Working()
|
|
if w.Status != StatusProposed || w.Ask == nil || w.Ask.Said != AskWaiting {
|
|
continue
|
|
}
|
|
entry, err := k.Asks.Record(w.Ask.ID)
|
|
if err != nil || entry == nil {
|
|
if err == nil && k.now().After(w.Ask.At.Add(AskLasts+time.Hour)) {
|
|
said = append(said, k.endAsk(ctx, e, w, "ended: the router keeps no record of its ask (refused before it was taken, or forgotten)"))
|
|
}
|
|
continue
|
|
}
|
|
var rec routerRecord
|
|
if json.Unmarshal(entry.Value, &rec) != nil || rec.State == "open" || rec.Warrant == nil {
|
|
continue
|
|
}
|
|
said = append(said, k.decide(ctx, e, w, *rec.Warrant))
|
|
}
|
|
return said, nil
|
|
}
|
|
|
|
// endAsk records an ask ended without the operator's choice: the record stays proposed.
|
|
func (k *KB) endAsk(ctx context.Context, e *Entry, w *Record, why string) string {
|
|
w = w.Clone()
|
|
w.Ask.Said = why
|
|
if err := k.Store.Save(ctx, Version{By: "the knowledge base", At: k.now(), Why: "the operator's ask " + why, Record: w, Insight: e.Pending != nil},
|
|
e.Pending == nil, false, e.Revision); err != nil {
|
|
return fmt.Sprintf("%s: the end of its ask could not be kept (%v)", w.Key(), err)
|
|
}
|
|
k.emit(EventReviewed, w, "the knowledge base", "the operator's ask "+why, e.Versions+1)
|
|
return fmt.Sprintf("%s: the operator's ask %s; it stays proposed", w.Key(), why)
|
|
}
|
|
|
|
// decide holds one closed ask's warrant to the record, and accepts the record when it is the operator's Approve.
|
|
func (k *KB) decide(ctx context.Context, e *Entry, w *Record, warrant asks.Warrant) string {
|
|
if warrant.Outcome != asks.OutcomeChosen {
|
|
why := "ended " + string(warrant.Outcome)
|
|
if warrant.Words != "" {
|
|
why += " (" + warrant.Words + ")"
|
|
}
|
|
return k.endAsk(ctx, e, w, why)
|
|
}
|
|
ask := composeAsk(w, w.Ask)
|
|
option, err := warrant.For(Module, ask)
|
|
if err == nil {
|
|
err = option.Performs(act(w, w.Ask, option.ID))
|
|
}
|
|
if err != nil {
|
|
k.logf("[mesh-knowledge] REFUSED the answer to the ask for %s: %v", w.Key(), err)
|
|
return k.endAsk(ctx, e, w, "was answered, and the answer does not hold here: "+err.Error())
|
|
}
|
|
switch option.ID {
|
|
case OptionApprove:
|
|
w = w.Clone()
|
|
w.Ask.Said, w.Ask.Warrant = AskApproved, warrant.Says()
|
|
r, err := k.accept(ctx, e, w, "the operator", "accepted on the operator's Approve after the knowledge base's review", "warrant: "+warrant.Says())
|
|
if err != nil {
|
|
return err.Error()
|
|
}
|
|
return fmt.Sprintf("%s: %s on the operator's warrant (%s)", r.Key(), r.Status, warrant.Says())
|
|
case OptionDecline:
|
|
w = w.Clone()
|
|
w.Ask.Warrant = warrant.Says()
|
|
return k.endAsk(ctx, e, w, "declined by the operator")
|
|
}
|
|
return k.endAsk(ctx, e, w, "was answered with "+option.ID+", which it does not offer")
|
|
}
|
|
|
|
// HeardEnd takes the router's word that one of this module's asks ended without a choice: nothing is accepted on
|
|
// such a word, so it needs no proof beyond its subject. A choice is only ever taken from the router's record.
|
|
func (k *KB) HeardEnd(w asks.Warrant) {
|
|
if w.Asker != Module || w.Outcome == asks.OutcomeChosen {
|
|
if _, err := k.Reconcile(); err != nil {
|
|
k.logf("[mesh-knowledge] reading the router's answers: %v", err)
|
|
}
|
|
return
|
|
}
|
|
ctx := context.Background()
|
|
k.mu.Lock()
|
|
defer k.mu.Unlock()
|
|
all, err := k.Store.All(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, e := range all {
|
|
r := e.Working()
|
|
if r.Ask != nil && r.Ask.ID == w.Ask && r.Ask.Said == AskWaiting {
|
|
k.logf("[mesh-knowledge] %s", k.decide(ctx, e, r, w))
|
|
}
|
|
}
|
|
}
|