From eadf301f9f94ab0fa72e39c45b3ea2c2e2ad4dc5 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 16:33:45 +0200 Subject: [PATCH 1/3] An ask reads at a glance on the phone: a headline, a summary and its points, the rest in a note (issues 383 and 639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 · ". 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. --- modules/claude-code/README.md | 9 +- .../cmd/claude-code/proposal_tools.go | 6 +- .../claude-code/cmd/claude-code/proposals.go | 116 ++++++++++++- .../cmd/claude-code/proposals_test.go | 60 +++++-- .../claude-code/cmd/claude-code/questions.go | 49 +++++- .../cmd/claude-code/questions_test.go | 11 +- modules/claude-code/go.mod | 2 +- modules/claude-code/go.sum | 8 +- modules/desk-channel/cmd/desk-channel/desk.go | 8 +- .../cmd/desk-channel/desk_test.go | 10 ++ modules/desk-channel/go.mod | 2 +- modules/desk-channel/go.sum | 2 + modules/mesh-knowledge/README.md | 12 +- .../cmd/mesh-knowledge/answers.go | 10 +- .../cmd/mesh-knowledge/glance_test.go | 111 +++++++++++++ .../mesh-knowledge/cmd/mesh-knowledge/kb.go | 4 +- .../mesh-knowledge/cmd/mesh-knowledge/main.go | 24 ++- .../cmd/mesh-knowledge/operator.go | 55 ++++++- .../cmd/mesh-knowledge/record.go | 41 ++++- .../cmd/mesh-knowledge/review_test.go | 7 +- .../cmd/mesh-knowledge/rules.go | 50 +++++- .../cmd/mesh-knowledge/rules_test.go | 7 +- modules/mesh-knowledge/go.mod | 2 +- modules/mesh-knowledge/go.sum | 2 + modules/messenger/README.md | 17 +- .../messenger/cmd/messenger/answers_test.go | 6 +- modules/messenger/cmd/messenger/asks.go | 43 ++++- modules/messenger/cmd/messenger/content.go | 71 ++++++-- .../messenger/cmd/messenger/feedback_test.go | 4 +- .../messenger/cmd/messenger/glance_test.go | 90 ++++++++++ modules/messenger/cmd/messenger/holder.go | 94 ++++++++--- .../cmd/messenger/onemessage_test.go | 4 +- .../messenger/cmd/messenger/review_test.go | 9 +- .../messenger/cmd/messenger/seatchannel.go | 2 +- modules/messenger/cmd/messenger/whole_test.go | 155 ++++++++++++++++++ modules/messenger/cmd/messenger/words.go | 9 +- modules/messenger/go.mod | 2 +- modules/messenger/go.sum | 6 +- modules/telegram/README.md | 1 + modules/telegram/cmd/telegram/bot.go | 10 +- modules/telegram/cmd/telegram/channel.go | 54 +++--- modules/telegram/cmd/telegram/channel_test.go | 77 ++++++++- modules/telegram/cmd/telegram/render.go | 96 +++++++++++ modules/telegram/cmd/telegram/render_test.go | 76 +++++++++ modules/telegram/go.mod | 2 +- modules/telegram/go.sum | 2 + 46 files changed, 1276 insertions(+), 162 deletions(-) create mode 100644 modules/mesh-knowledge/cmd/mesh-knowledge/glance_test.go create mode 100644 modules/messenger/cmd/messenger/glance_test.go create mode 100644 modules/messenger/cmd/messenger/whole_test.go create mode 100644 modules/telegram/cmd/telegram/render.go create mode 100644 modules/telegram/cmd/telegram/render_test.go diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index e85cdbed..94e65a68 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -179,9 +179,12 @@ said in the render's answer. What was registered before is still rendered and ma hand-over, the context a later session needs — or with `remove` for a section approved before. Nothing becomes active: the proposal is kept in the module's `proposals` state and the operator is asked through the operator channel (`operator-channel`, hq ADR 0259) with Approve and Decline, both at the level approve, so only a channel that -proves who answered (Telegram) carries either. The phone shows the whole text when it is short and every line may -leave the mesh, else its first lines and a fingerprint; the whole text is read at the controller's terminal with -`mesh-cli ask claude-code claude_code_proposals '{"id": ""}'`. Decline, or no answer within a day, discards it. +proves who answered (Telegram) carries either. The ask reads at a glance (hq issue 639): its explanation is one +line saying what the proposal does — the proposer's `summary`, at most 300 characters, or the module's own words — +and its Details, which the phone shows collapsed after the answers, carry the text (whole when it is short and every +line may leave the mesh, else its first lines), where it came from and its fingerprint; the whole text is read at the +controller's terminal with `mesh-cli ask claude-code claude_code_proposals '{"id": ""}'`. A proposal made before +that change is asked, and its warrant held, in the form it was made in (`form`). Decline, or no answer within a day, discards it. **Every node proves the approval itself**: it reads the router's own record of the ask under this module's name, judges the record as this module would have made it, composes the ask again from the proposal and holds the warrant to it (the same ask digest, the option's bound act, the text's digest, in time), and only then writes the section into its own `approved.json`, which the managed diff --git a/modules/claude-code/cmd/claude-code/proposal_tools.go b/modules/claude-code/cmd/claude-code/proposal_tools.go index b9005e7a..84af4558 100644 --- a/modules/claude-code/cmd/claude-code/proposal_tools.go +++ b/modules/claude-code/cmd/claude-code/proposal_tools.go @@ -130,7 +130,7 @@ func proposalTools(p Paths) []stdio.Tool { text, _ = a["content"].(string) } return Propose(p, ProposeInput{Scope: scopeOf(a), Nodes: nodesOf(a["nodes"]), Name: strArg(a, "name"), - Text: text, Remove: boolArg(a, "remove")}, proposing.Store, proposing.Bus, proposing.Now()) + Text: text, Remove: boolArg(a, "remove"), Summary: strArg(a, "summary")}, proposing.Store, proposing.Bus, proposing.Now()) }}, {Name: "claude_code_proposals", Description: "The instruction sections proposed for the operator's approval (novox/hq ADR 0274), newest first: what " + @@ -164,14 +164,14 @@ func questionTools(p Paths, notLaunched error) []stdio.Tool { "operator's word to you; it authorises no call that needs the operator's approval.", Input: map[string]any{ "headline": str("what is decided, in a few plain words, at most 60 characters, e.g. 'Merge the resolver fix tonight?'"), - "context": str("what the operator decides and why it matters, in plain words, at most 1500 bytes"), + "context": str("what the operator decides and why it matters, in plain words: two or three short sentences, one line, at most 300 characters"), "options": map[string]any{"type": "array", "description": "two to four answers; exactly one recommended", "items": map[string]any{"type": "object", "properties": map[string]any{ "label": str("the button's words, at most 24 characters"), "means": str("what you do when it is chosen, in plain words"), "recommended": map[string]any{"type": "boolean", "description": "true on the one you recommend"}, }, "required": []string{"label", "means"}}}, - "why": str("why the recommended option is the one you recommend"), + "why": str("why the recommended option is the one you recommend, at most 120 bytes"), "on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"), "by": str("required: your session's name, as who@node or who, e.g. fix-589@g14"), "expires_minutes": map[string]any{"type": "integer", diff --git a/modules/claude-code/cmd/claude-code/proposals.go b/modules/claude-code/cmd/claude-code/proposals.go index 78fc06ce..dbde6538 100644 --- a/modules/claude-code/cmd/claude-code/proposals.go +++ b/modules/claude-code/cmd/claude-code/proposals.go @@ -104,6 +104,12 @@ type Proposal struct { Digest string `json:"digest"` From string `json:"from"` At time.Time `json:"at"` + // Summary is what the operator's phone shows of it, at a glance, in its proposer's words; empty, the module + // says what it does (novox/hq issue 639). + Summary string `json:"summary,omitempty"` + // Form is how its ask is composed, so every node composes the same one again to hold the warrant to it: 0 for + // a proposal made before issue 639 (its text in the explanation), AskGlance for one read at a glance. + Form int `json:"form,omitempty"` // Asked is empty once the ask was published, or says why it was not; nothing composes from it. Asked string `json:"asked,omitempty"` } @@ -164,9 +170,109 @@ func (p Proposal) Act(option string) asks.Act { "at": p.At.UTC().Format(time.RFC3339Nano)} } +// AskGlance is the form of an ask read at a glance (Proposal.Form, Question.Form; novox/hq issue 639). +const AskGlance = 1 + // Ask is the ask for a proposal, composed from it alone: every node composes the same one, and holds the -// router's warrant to it. +// router's warrant to it — in the form it was asked in. func (p Proposal) Ask() asks.Ask { + if p.Form == AskGlance { + return p.glanceAsk() + } + return p.listedAsk() +} + +// glanceAsk is the ask read at a glance on the phone (novox/hq issue 639): its headline, and as its explanation +// the summary of what it does; the text, where it came from, its fingerprint and where to read all of it are its +// Details, which the router notes after the answers, collapsed where the channel can. +func (p Proposal) glanceAsk() asks.Ask { + verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action] + shown, whole := excerpt(p.Text) + part := "" + if p.Action != ActionRemove && !whole { + part = " Part shown" + } + headline := fmt.Sprintf("%s Claude instructions '%s'?%s", verb, p.Name, part) + if len([]rune(headline)) > asks.HeadlineLength { + headline = verb + " Claude instructions?" + part + } + var d strings.Builder + if p.Action == ActionRemove { + fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC. The approved text it removes has the fingerprint %s.", + p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Fingerprint()) + } else { + if whole { + d.WriteString("The text:\n" + shown + "\n") + } else { + d.WriteString("NOT SHOWN IN FULL here. Its first lines:\n" + shown + "\n") + } + fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC: %d lines, %d bytes, fingerprint %s.", + p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Lines(), len(p.Text), p.Fingerprint()) + } + fmt.Fprintf(&d, "\nRead all of it at the controller's terminal: mesh-cli ask claude-code claude_code_proposals '{\"id\": \"%s\"}'.", p.ID) + does := "the text becomes part of those sessions' instructions" + if p.Action == ActionRemove { + does = "the instructions are removed from those sessions" + } + return p.withOptions(asks.Ask{ID: p.ID, Headline: headline, Explanation: p.summary(), Details: d.String()}, does) +} + +// summary is what the proposal does, at a glance: its proposer's words, or the module's. +func (p Proposal) summary() string { + if s := strings.TrimSpace(p.Summary); s != "" { + return s + } + lines := fmt.Sprintf("these %d lines", p.Lines()) + if p.Lines() == 1 { + lines = "this line" + } + where := strings.ToUpper(p.where()[:1]) + p.where()[1:] + switch p.Action { + case ActionRemove: + return fmt.Sprintf("%s stops reading the instructions '%s' from its next start.", where, p.Name) + case ActionReplace: + return fmt.Sprintf("%s reads %s in place of the instructions '%s', as the mesh's own word, from its next start until "+ + "they are removed the same way.", where, lines, p.Name) + } + return fmt.Sprintf("%s reads %s as the mesh's own word, from its next start until they are removed the same way.", where, lines) +} + +// SummaryProblem says why a proposer's summary cannot be shown at a glance, or "". +func SummaryProblem(summary string) string { + switch s := strings.TrimSpace(summary); { + case s == "": + return "" + case strings.Contains(s, "\n"): + return "the summary is more than one line" + case len([]rune(s)) > asks.SummaryLength: + return fmt.Sprintf("the summary is %d characters, more than the %d a phone shows at a glance", len([]rune(s)), asks.SummaryLength) + case !shownLine(s): + return "the summary names a path, an address, a host name or a long word, which a phone is not shown" + } + return "" +} + +// 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 (p Proposal) withOptions(a asks.Ask, does string) asks.Ask { + a.Who, a.Expires, a.OnExpiry = asks.Operator, p.At.Add(ProposalLasts), "the proposal is discarded; nothing changes" + a.About = "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name + if len(p.Nodes) == 0 { + a.About = "instructions." + p.Scope + "." + p.Name + } + for _, o := range []struct{ id, label, does string }{ + {OptionApprove, "Approve", does}, + {OptionDecline, "Decline", "nothing changes; the proposal is discarded"}, + } { + binds, _ := asks.ActDigest(p.Act(o.id)) + a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds}) + } + return a +} + +// listedAsk is the ask as it was composed before issue 639, the text in its explanation: composed only to hold the +// warrant of a proposal made then. +func (p Proposal) listedAsk() asks.Ask { verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action] shown, whole := excerpt(p.Text) part := "" @@ -617,6 +723,8 @@ type ProposeInput struct { Name string Text string Remove bool + // Summary is what the operator's phone shows of it, in one line (novox/hq issue 639); empty, the module says it. + Summary string } // AtTheTerminalInstead is the other way, said whenever a proposal is refused for want of a channel. @@ -696,7 +804,11 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time return nil, err } active, isActive := approved.Sections[sectionKey(in.Scope, nodes, in.Name)] - prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC()} + if problem := SummaryProblem(in.Summary); problem != "" { + return refuse(problem) + } + prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC(), + Summary: strings.TrimSpace(in.Summary), Form: AskGlance} switch { case in.Remove: if !isActive { diff --git a/modules/claude-code/cmd/claude-code/proposals_test.go b/modules/claude-code/cmd/claude-code/proposals_test.go index c1a3d1b8..5d35e8ac 100644 --- a/modules/claude-code/cmd/claude-code/proposals_test.go +++ b/modules/claude-code/cmd/claude-code/proposals_test.go @@ -147,14 +147,17 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T if err := a.Check(t0); err != nil { t.Fatalf("the router would refuse the ask: %v", err) } - if !strings.HasPrefix(a.Explanation, "Add instructions 'handoff-from-g14' to every Claude session on the mesh?") { - t.Errorf("the phone does not say what is asked first: %q", a.Explanation) + // At a glance (issue 639): what it does is the explanation, one line; the text is the Details, which the + // router notes collapsed after the answers. + if a.Explanation != "Every Claude session on the mesh reads these 4 lines as the mesh's own word, from its next start until they are removed the same way." || + asks.Glance(a.Explanation) != nil { + t.Errorf("the phone does not say what is asked at a glance: %q", a.Explanation) } if a.Headline != "Add Claude instructions 'handoff-from-g14'?" { t.Errorf("headline: %q", a.Headline) } - if !strings.Contains(a.Explanation, "The whole text:\n\n"+strings.TrimRight(handover, "\n")) { - t.Errorf("a short text is shown whole: %q", a.Explanation) + if !strings.HasPrefix(a.Details, "The text:\n"+strings.TrimRight(handover, "\n")+"\nProposed through the claude-code module on g14 at ") { + t.Errorf("a short text is not shown whole: %q", a.Details) } if len(a.Options) != 2 { t.Fatalf("options: %+v", a.Options) @@ -294,7 +297,7 @@ func TestARemovalNeedsTheSameApprovalAndARecordDeletedRemovesNothing(t *testing. t.Fatal(err) } rid := out["proposed"].(string) - if !strings.HasPrefix(r.published[rid].Explanation, "Remove the instructions 'handoff-from-g14' from every Claude session on the mesh?") { + if r.published[rid].Explanation != "Every Claude session on the mesh stops reading the instructions 'handoff-from-g14' from its next start." { t.Errorf("removal ask: %q", r.published[rid].Explanation) } if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") { @@ -326,7 +329,7 @@ func TestANodeScopedSectionReachesOnlyItsNodes(t *testing.T) { t.Fatal(err) } id := out["proposed"].(string) - if !strings.HasPrefix(r.published[id].Explanation, "Add instructions 'builds' to every Claude session on shanks?") { + if !strings.HasPrefix(r.published[id].Explanation, "Every Claude session on shanks reads this line as the mesh's own word") { t.Errorf("%q", r.published[id].Explanation) } r.close(r.chose(id, OptionApprove, t0.Add(time.Hour))) @@ -417,15 +420,16 @@ func TestALongTextIsShownInPartAndReadWholeAtTheTerminal(t *testing.T) { } id := out["proposed"].(string) a := r.published[id] - if strings.Contains(a.Explanation, "/etc/claude-code") || !strings.Contains(a.Explanation, "[1 line(s) not shown here") { - t.Errorf("a line with a path was shown: %q", a.Explanation) + if strings.Contains(a.Details, "/etc/claude-code") || !strings.Contains(a.Details, "[1 line(s) not shown here") || + !strings.HasPrefix(a.Details, "NOT SHOWN IN FULL here.") || !strings.HasSuffix(a.Headline, "Part shown") { + t.Errorf("a line with a path was shown, or the cut not said: %q", a.Details) } - if !strings.Contains(a.Explanation, "mesh-cli ask claude-code claude_code_proposals") || - !strings.Contains(a.Explanation, "fingerprint there must be "+out["fingerprint"].(string)) { - t.Errorf("the phone does not say where to read all of it: %q", a.Explanation) + if !strings.Contains(a.Details, "mesh-cli ask claude-code claude_code_proposals") || + !strings.Contains(a.Details, "fingerprint "+out["fingerprint"].(string)) { + t.Errorf("the phone does not say where to read all of it: %q", a.Details) } - if len(a.Explanation) > 3000 { - t.Errorf("the explanation is %d bytes", len(a.Explanation)) + if len(a.Details) > 3000 || asks.Glance(a.Explanation) != nil { + t.Errorf("the details are %d bytes; the explanation %q", len(a.Details), a.Explanation) } list, err := ListProposals(p, store, r, id, t0) if err != nil { @@ -739,3 +743,33 @@ func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) { t.Errorf("decided %q", got) } } + +// A proposer's summary is the explanation, held to a glance; a proposal made before issue 639 is composed as it was +// asked, so the warrant for it still holds after the change. +func TestAProposalIsAskedAtAGlanceAndAnOlderOneAsItWasAsked(t *testing.T) { + p, _ := node(t, "g14") + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Text: handover, + Summary: "Every session learns how hand-overs are written, so the next one reads them the same way."}, store, r, t0) + if err != nil { + t.Fatal(err) + } + id := out["proposed"].(string) + a := r.published[id] + if a.Explanation != "Every session learns how hand-overs are written, so the next one reads them the same way." { + t.Errorf("the proposer's summary is not the explanation: %q", a.Explanation) + } + for _, bad := range []string{strings.Repeat("word ", 70), "one\ntwo", "Read it in /etc/claude-code first."} { + if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "other", Text: handover, Summary: bad}, store, r, t0); err == nil || + !strings.Contains(err.Error(), "the summary") { + t.Errorf("the summary %q was taken: %v", bad, err) + } + } + prop, _ := readProposal(store, id) + older := prop + older.Form = 0 + listed := older.Ask() + if !strings.Contains(listed.Explanation, "The whole text:") || listed.Details != "" || listed.Digest() == prop.Ask().Digest() { + t.Errorf("a proposal made before is not composed as it was asked: %q", listed.Explanation) + } +} diff --git a/modules/claude-code/cmd/claude-code/questions.go b/modules/claude-code/cmd/claude-code/questions.go index e7c879f2..7247d352 100644 --- a/modules/claude-code/cmd/claude-code/questions.go +++ b/modules/claude-code/cmd/claude-code/questions.go @@ -69,9 +69,9 @@ const ( // The bounds of what a question says. const ( FewestQuestionOptions = 2 - MostContextBytes = 1500 + MostContextBytes = asks.SummaryLength MostMeansBytes = 200 - MostWhyBytes = 300 + MostWhyBytes = 120 MostOnExpiryBytes = 200 ) @@ -131,6 +131,8 @@ type Question struct { From string `json:"from"` At time.Time `json:"at"` Expires time.Time `json:"expires"` + // Form is how its ask is composed (AskGlance since novox/hq issue 639), so the answer is held to the ask asked. + Form int `json:"form,omitempty"` // Asked is empty once the ask was published, or says why it was not; nothing composes from it. Asked string `json:"asked,omitempty"` // Ended is the router's word that the ask ended without a choice, as heard on the decided subject: a refused ask @@ -160,8 +162,41 @@ func (q Question) Act(option string) asks.Act { return act } -// Ask is the ask for a question, composed from it alone. +// Ask is the ask for a question, composed from it alone, in the form it was asked in. func (q Question) Ask() asks.Ask { + if q.Form == AskGlance { + return q.glanceAsk() + } + return q.listedAsk() +} + +// glanceAsk is the question read at a glance on the phone (novox/hq issue 639): its context, then the recommended +// answer and why as its one point; who asked and what the answer does are its Details. +func (q Question) glanceAsk() asks.Ask { + explanation := strings.TrimSpace(q.Context) + if rec, ok := q.recommended(); ok { + explanation += fmt.Sprintf("\n• Recommended: %s, because %s.", rec.Label, strings.TrimRight(strings.TrimSpace(q.Why), ".")) + } + details := fmt.Sprintf("Asked by the agent session %s on %s through the claude-code module, at %s UTC. Your answer is told "+ + "to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan")) + return q.withOptions(asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details}) +} + +func (q Question) withOptions(a asks.Ask) asks.Ask { + a.Who, a.Expires, a.OnExpiry = asks.Operator, q.Expires, q.OnExpiry + for _, o := range q.Options { + does := o.Means + if o.Recommended { + does = "Recommended. " + does + } + binds, _ := asks.ActDigest(q.Act(o.ID)) + a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds}) + } + return a +} + +// listedAsk is the ask as it was composed before issue 639: composed only to hold the answer to a question asked then. +func (q Question) listedAsk() asks.Ask { var b strings.Builder b.WriteString(strings.TrimSpace(q.Context)) if rec, ok := q.recommended(); ok { @@ -199,8 +234,10 @@ func QuestionProblem(q Question) string { switch { case strings.TrimSpace(q.Context) == "": add("it gives no context: say in plain words what the operator decides and why it matters") - case len(q.Context) > MostContextBytes: - add("its context is %d bytes, more than the %d a phone shows in one glance", len(q.Context), MostContextBytes) + case len([]rune(q.Context)) > MostContextBytes: + add("its context is %d characters, more than the %d a phone shows in one glance", len([]rune(q.Context)), MostContextBytes) + case strings.Contains(strings.TrimSpace(q.Context), "\n"): + add("its context is more than one line: a phone shows it at a glance as one short paragraph") } if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions { add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions) @@ -322,7 +359,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now } q := Question{ID: newQuestionID(), Headline: strings.TrimSpace(in.Headline), Context: strings.TrimSpace(in.Context), Why: strings.TrimSpace(in.Why), OnExpiry: strings.TrimSpace(in.OnExpiry), By: by, From: p.Node, At: now.UTC(), - Expires: now.UTC().Add(life)} + Expires: now.UTC().Add(life), Form: AskGlance} for i, o := range in.Options { q.Options = append(q.Options, QuestionOption{ID: fmt.Sprintf("o%d", i+1), Label: strings.TrimSpace(o.Label), Means: strings.TrimSpace(o.Means), Recommended: o.Recommended}) diff --git a/modules/claude-code/cmd/claude-code/questions_test.go b/modules/claude-code/cmd/claude-code/questions_test.go index 7bd5e7c0..db3b249f 100644 --- a/modules/claude-code/cmd/claude-code/questions_test.go +++ b/modules/claude-code/cmd/claude-code/questions_test.go @@ -68,10 +68,13 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) { if !strings.HasPrefix(a.Options[0].Does, "Recommended. ") || strings.HasPrefix(a.Options[1].Does, "Recommended") { t.Errorf("the recommendation is not on the recommended option alone: %+v", a.Options) } - for _, want := range []string{"Recommended: Merge now, because it is reviewed and the checks are green.", - "the agent session fix-589 on g14", "performs nothing by itself"} { - if !strings.Contains(a.Explanation, want) { - t.Errorf("the explanation does not say %q:\n%s", want, a.Explanation) + // At a glance (issue 639): the context and the recommendation are the explanation; who asks is the Details. + if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || asks.Glance(a.Explanation) != nil { + t.Errorf("the explanation does not read at a glance:\n%s", a.Explanation) + } + for _, want := range []string{"the agent session fix-589 on g14", "performs nothing by itself"} { + if !strings.Contains(a.Details, want) || strings.Contains(a.Explanation, want) { + t.Errorf("the details do not say %q:\n%s", want, a.Details) } } if err := a.Check(t0); err != nil { diff --git a/modules/claude-code/go.mod b/modules/claude-code/go.mod index 2bf6e48e..6562a740 100644 --- a/modules/claude-code/go.mod +++ b/modules/claude-code/go.mod @@ -2,6 +2,6 @@ module claude-code go 1.25.0 -require git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 require gopkg.in/yaml.v3 v3.0.1 diff --git a/modules/claude-code/go.sum b/modules/claude-code/go.sum index 29d36e70..f09ac821 100644 --- a/modules/claude-code/go.sum +++ b/modules/claude-code/go.sum @@ -1,7 +1,7 @@ -git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= -git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= -git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI= -git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.13 h1:Su4JYpZ+zhNovkGA2DgxiZdcuHpjw2tPJzc/7PljhXg= +git.novox.be/novox/mesh-sdk/go v0.1.13/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/modules/desk-channel/cmd/desk-channel/desk.go b/modules/desk-channel/cmd/desk-channel/desk.go index 880a8939..67ed2746 100644 --- a/modules/desk-channel/cmd/desk-channel/desk.go +++ b/modules/desk-channel/cmd/desk-channel/desk.go @@ -233,7 +233,13 @@ func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) { case m.Silent: urgency = "low" } - args := map[string]any{"summary": m.Title, "body": html.EscapeString(m.Body), "urgency": urgency, "app_name": "mesh"} + // The note — who asked, what each answer does, the expiry — follows the body after a blank line: a + // notification has no smaller text, and the note is never left out (novox/hq issue 639). + text := strings.TrimSpace(m.Body) + if note := strings.TrimSpace(m.Note); note != "" { + text = strings.TrimSpace(text + "\n\n" + note) + } + args := map[string]any{"summary": m.Title, "body": html.EscapeString(text), "urgency": urgency, "app_name": "mesh"} if replace != 0 { args["replace_id"] = replace } diff --git a/modules/desk-channel/cmd/desk-channel/desk_test.go b/modules/desk-channel/cmd/desk-channel/desk_test.go index 69b8c72f..b4ad168f 100644 --- a/modules/desk-channel/cmd/desk-channel/desk_test.go +++ b/modules/desk-channel/cmd/desk-channel/desk_test.go @@ -103,6 +103,16 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) { } } +// The note follows the body after a blank line: the desk has no smaller text, and leaves nothing out (issue 639). +func TestTheNoteFollowsTheBody(t *testing.T) { + d, shown, _, _ := newDesk(t, "laptop") + take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Body: "The summary.", Note: "Asked by mesh-knowledge at 16:05.", + Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}}) + if last := (*shown)[len(*shown)-1]; last.args["body"] != "The summary.\n\nAsked by mesh-knowledge at 16:05." { + t.Errorf("the body: %q", last.args["body"]) + } +} + func TestAnEditReplacesTheNotification(t *testing.T) { d, shown, _, _ := newDesk(t, "laptop") take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}}) diff --git a/modules/desk-channel/go.mod b/modules/desk-channel/go.mod index d18c0d42..c3cc8428 100644 --- a/modules/desk-channel/go.mod +++ b/modules/desk-channel/go.mod @@ -2,4 +2,4 @@ module deskchannel go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.10 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 diff --git a/modules/desk-channel/go.sum b/modules/desk-channel/go.sum index 8446865c..b5bb9911 100644 --- a/modules/desk-channel/go.sum +++ b/modules/desk-channel/go.sum @@ -1,2 +1,4 @@ git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o= git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/mesh-knowledge/README.md b/modules/mesh-knowledge/README.md index 8e0ec8fc..a8d11cc3 100644 --- a/modules/mesh-knowledge/README.md +++ b/modules/mesh-knowledge/README.md @@ -72,7 +72,7 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel ## Writing a record 1. `reserve` a number for its kind. -2. `propose` the record whole. It is refused, naming every reason at once, when it breaks one of the **29 rules +2. `propose` the record whole. It is refused, naming every reason at once, when it breaks one of the **30 rules that judge one record or its prose**. They are hq's checks moved to the write; `PerRecordRules` in `rules.go` lists each one with the check it comes from: - 25 on the structure: kind, reserved number, title, topic, date, deciders, research status, at least one @@ -81,7 +81,10 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel supersedes or changes is accepted, a changed clause is in force or changed (a drafted one too), a superseded record cited beside its superseder, a graduated effort says what it became, and the links in its text resolve; - 4 on the prose: no word the glossary retired, no daemon's line, a deferral names its tracking issue, and a - progressive insight in the dated, marked form. + progressive insight in the dated, marked form; + - 1 on the operator's ask (hq issue 639): `brief`, a record that needs the operator carries a brief its ask reads + at a glance — a headline of at most 60 characters, a one-line summary of at most 300, at most three points of + at most 160, and no path, address, dotted name or long word. It holds records dated from 2026-10-13. A rule holds a record from the day hq's checks began to hold it (`RuleFrom` in `rules.go`): - retired words from 2026-10-07 and daemons' lines from 2026-10-09 for a research effort; for a decision record @@ -122,7 +125,10 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel - On approve the record and its clauses are accepted and in force. Every clause it changes moves to `changed`, and every record it supersedes moves to `superseded`, each as a new version of that record. A record marked `needs-operator` is first asked of the operator (an ask that cannot be published is taken back, and the record is reviewed again later): an ask with - Approve and Decline, both at the level approve, each bound to this version's digest. It is + Approve and Decline, both at the level approve, each bound to this version's digest. The ask reads at a glance + (hq issue 639): the record's `brief` — a headline saying what it changes for the operator, a summary, at most three + points — and as its Details who proposed it, the review, the fingerprint and how to read its clauses; the clauses + are never listed. An ask made before that change is composed again as it was asked (`form`). It is accepted only on the router's warrant for Approve, read back from the router's own record and held to the ask composed again. A record changed after it was asked finds the digests differ, and nothing is accepted. The warrant is kept with the record. diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/answers.go b/modules/mesh-knowledge/cmd/mesh-knowledge/answers.go index 9a25ea0e..d632437a 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/answers.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/answers.go @@ -309,9 +309,11 @@ type Got struct { Source string `json:"source,omitempty"` Reserved string `json:"reserved,omitempty"` NeedsOperat bool `json:"needs-operator,omitempty"` - Cut string `json:"cut,omitempty"` - Bound int `json:"bound"` - Read string `json:"read,omitempty"` + // Brief is what the operator's ask shows of it (issue 639). + Brief *Brief `json:"brief,omitempty"` + Cut string `json:"cut,omitempty"` + Bound int `json:"bound"` + Read string `json:"read,omitempty"` // Reconstructed, Amended and Touches are the record's fields of those names; Fields, any other it holds as // written (hq issue 487). @@ -349,7 +351,7 @@ func (k *KB) Get(a GetAsk) (*Got, error) { bound := boundOf(a.Bound) g := &Got{Record: key.String(), Title: r.Title, Status: r.Status, Topic: r.Topic, Date: r.Date, Deciders: r.Deciders, Version: version, Versions: e.Versions, Insights: r.Insights, Reviews: r.Reviews, Drafting: r.Drafting, Source: r.Source, - NeedsOperat: r.NeedsOperator, Bound: bound, Since: k.since(ctx, key), + NeedsOperat: r.NeedsOperator, Brief: r.Brief, Bound: bound, Since: k.since(ctx, key), Reconstructed: r.Reconstructed, Amended: r.Amended, Touches: r.Touches, Fields: r.Extra} if r.Status == StatusReserved || r.Status == StatusWithdrawn { g.Reserved = fmt.Sprintf("reserved by %s at %s for %q", r.ReservedBy, r.ReservedAt.Format("2006-01-02 15:04 UTC"), r.ReservedFor) diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/glance_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/glance_test.go new file mode 100644 index 00000000..9b2d5779 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/glance_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "fmt" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// novox/hq issue 639: the operator would not read the ask for ADR 0343 — thirty plain lines, every clause one per +// line. A record that needs the operator is asked with its brief: a headline that says what it changes for them, a +// summary, at most three points; who proposed it, the fingerprint and how to read the clauses are the Details. + +// adr0343 is ADR 0343 as it was asked on 2026-10-13: thirteen clauses, and the brief its author would now give. +func adr0343() *Record { + r := &Record{Kind: KindDecision, Number: 343, Status: StatusProposed, Date: "2026-10-13", NeedsOperator: true, + Title: "A session's memory follows the operator: the agent module carries each project's memory folder to every node, and a file changed on two keeps both", + Author: "fix-367@g14", + Brief: &Brief{Headline: "Your agents' memory follows you to every machine", + Summary: "What an agent learns in a project's memory is copied to every machine that runs the agent, so a session " + + "anywhere starts with it. A file changed on two machines keeps both versions and warns you; secrets in memory " + + "files are screened, though not perfectly.", + Points: []string{"Only memory folders travel: links and files over 128 KiB stay where they are", + "A conflict reaches you as a warning that needs you", + "A deleted file is marked, and a newer change elsewhere outlives the deletion"}}} + leads := []string{"Only the memory folders are carried", "Some memory files are not carried, and each is listed with why", + "The memory folders become paths the module's own process writes", "Memory moves only between homes of the same kind", + "The module's process on each node notices a change and puts it in the module's state", + "Every other node writes the new version where its own file holds no change it did not carry, and keeps the version replaced", + "When a file changed on two nodes, both versions are kept, the newer first", + "A conflict is sent to the operator as a warning that needs them", + "A deletion is carried as a removal marker, and a change outlives it", + "On a node's first carrying, every version learned before is kept", "One read-only tool shows what is carried", + "A value put straight into the state is not yet told apart from one the module put there", "How it is checked"} + for i, l := range leads { + r.Clauses = append(r.Clauses, Clause{N: i + 1, Text: "**" + l + ".** The statement.", Status: ClauseProposed, Source: SourceWritten}) + } + return r +} + +func TestADecisionAskReadsAtAGlance(t *testing.T) { + r := adr0343() + at := time.Date(2026, 10, 13, 14, 5, 0, 0, time.UTC) + a := composeAsk(r, &OperatorAsk{ID: "kb-decision-343-0d1e2f3a", At: at, Version: 12, Form: AskGlance}) + if err := a.Check(at); err != nil { + t.Fatal(err) + } + if a.Headline != "Your agents' memory follows you to every machine" { + t.Errorf("the headline is not what it changes for the operator: %q", a.Headline) + } + if err := asks.Glance(a.Explanation); err != nil { + t.Errorf("%v:\n%s", err, a.Explanation) + } + if n := strings.Count(a.Explanation, "\n• "); n != 3 || !strings.HasPrefix(a.Explanation, "What an agent learns") { + t.Errorf("the summary and its %d points:\n%s", n, a.Explanation) + } + for _, c := range r.Clauses { + if strings.Contains(a.Explanation+a.Details, c.Lead()) { + t.Errorf("the ask lists the clause %d: %q", c.N, c.Lead()) + } + } + details := strings.Split(a.Details, "\n") + if len(details) != 2 || !strings.Contains(details[0], "ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it.") || + !strings.Contains(details[0], "Fingerprint "+fingerprint(r.Digest())+".") || + details[1] != "Read its 13 clauses with the knowledge base's get, number 343." { + t.Errorf("the details:\n%s", a.Details) + } + // The ask made before the brief is composed again as it was, so its warrant still holds after the change. + old := composeAsk(r, &OperatorAsk{ID: "kb-decision-343-0d1e2f3a", At: at, Version: 12}) + if !strings.Contains(old.Explanation, "Its 13 clause(s):") || old.Headline != "Accept ADR 0343?" || old.Details != "" || old.Digest() == a.Digest() { + t.Errorf("an ask made before the brief is not composed as it was asked:\n%s", old.Explanation) + } + // Without a brief (a record proposed before the rule), the glance is the title, and no clause. + r.Brief = nil + a = composeAsk(r, &OperatorAsk{ID: "kb-decision-343-0d1e2f3a", At: at, Version: 12, Form: AskGlance}) + if a.Headline != "Approve ADR 0343?" || asks.Glance(a.Explanation) != nil || strings.Contains(a.Explanation, "Only the memory folders") { + t.Errorf("without a brief: %q\n%s", a.Headline, a.Explanation) + } +} + +// A brief is held to what the phone reads at a glance, each way it fails named. +func TestABriefIsHeldToAGlance(t *testing.T) { + good := adr0343().Brief + if got := briefProblems(good); len(got) != 0 { + t.Fatalf("the brief of ADR 0343 is refused: %v", got) + } + for name, c := range map[string]struct { + change func(b *Brief) + says string + }{ + "no headline": {func(b *Brief) { b.Headline = "" }, "headline is empty"}, + "long headline": {func(b *Brief) { b.Headline = strings.Repeat("memory ", 10) }, "longer than 60"}, + "no summary": {func(b *Brief) { b.Summary = " " }, "summary is empty"}, + "long summary": {func(b *Brief) { b.Summary = strings.Repeat("A short sentence. ", 20) }, "summary is"}, + "four points": {func(b *Brief) { b.Points = append(b.Points, "one more") }, "4 points"}, + "a path": {func(b *Brief) { b.Points[0] = "Carried from the agent's home under .claude/projects" }, "names a path"}, + "two lines": {func(b *Brief) { b.Summary = "One.\nTwo." }, "more than one line"}, + } { + b := *good + b.Points = append([]string{}, good.Points...) + c.change(&b) + if got := fmt.Sprint(briefProblems(&b)); !strings.Contains(got, c.says) { + t.Errorf("%s: %s", name, got) + } + } + if got := briefProblems(nil); len(got) != 1 || !strings.Contains(got[0], "carries a brief") { + t.Errorf("no brief: %v", got) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go index fe6b8a86..f82917b8 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go @@ -238,6 +238,8 @@ type ProposeAsk struct { Links []LinkAsk Homes []string NeedsOperator bool + // Brief is what the operator's ask shows (novox/hq issue 639). + Brief *Brief // Status is a research effort's: active, graduated or abandoned. Status string // Insight, for an accepted record, is what stood before: the proposal is a progressive insight. @@ -280,7 +282,7 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) { } r := &Record{Kind: a.Kind, Number: a.Number, Title: strings.TrimSpace(a.Title), Topic: strings.TrimSpace(a.Topic), Status: StatusProposed, Date: strings.TrimSpace(a.Date), Deciders: a.Deciders, Effort: a.Status, - NeedsOperator: a.NeedsOperator, Sections: a.Sections, Homes: a.Homes, Author: caller} + NeedsOperator: a.NeedsOperator, Brief: a.Brief, Sections: a.Sections, Homes: a.Homes, Author: caller} if r.Date == "" { r.Date = k.now().Format("2006-01-02") } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go index c1f1f2e3..2c4f1153 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go @@ -914,8 +914,8 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool { Description: "Propose a record whole under a reserved number: its title, topic, date and deciders; for a decision its " + "clauses, each opening with a bold lead (\"**What is decided.** …\"), and its reasoning in sections (Context, " + "Considered Options, Consequences, How it is checked, References); for a research effort its status and documents; its " + - "links (supersedes, changes \"0276 §4\", extends, became, cites), and whether it needs the operator. Refused, naming " + - "every reason at once and writing nothing, when it breaks one of the 29 rules that judge one record or its prose — a " + + "links (supersedes, changes \"0276 §4\", extends, became, cites), and whether it needs the operator, with the brief its ask shows. Refused, naming " + + "every reason at once and writing nothing, when it breaks one of the 30 rules that judge one record or its prose — a " + "link to nothing, a clause without a bold lead, a superseded record cited alone, an unknown topic, a retired word. A " + "proposal is revised by proposing it again; an accepted record changes only by a progressive insight (insight: what " + "stood before). The knowledge base then reviews each version itself, in a judgement that sees only the record, the " + @@ -937,13 +937,19 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool { "to": text("the target"), "from": integer("the clause of this record it goes out from")}, "required": []string{"kind", "to"}}}, "homes": texts("hq documents that cite it, such as a design's path; a decision needs one unless an accepted record links it"), "needs-operator": flag("it holds only once the operator approves it"), - "status": map[string]any{"type": "string", "enum": ResearchStatuses, "description": "a research effort's status"}, - "insight": text("for an accepted record: a progressive insight, saying what stood before"), - "why": text("one line: why this version"), + "brief": map[string]any{"type": "object", "description": "required with needs-operator: what the operator's phone shows " + + "in place of the clauses (issue 639) — a headline of at most 60 characters saying what it changes for the operator, " + + "not the title; a summary of two or three short sentences, at most 300 characters, on what changes, why and what it " + + "costs or risks; at most three points of at most 160 characters. No path, address, dotted name or long word", + "properties": map[string]any{"headline": text("what it changes for the operator"), "summary": text("two or three short sentences"), + "points": texts("at most three, only where they add something")}, "required": []string{"headline", "summary"}}, + "status": map[string]any{"type": "string", "enum": ResearchStatuses, "description": "a research effort's status"}, + "insight": text("for an accepted record: a progressive insight, saying what stood before"), + "why": text("one line: why this version"), }), Run: func(m map[string]any) (any, error) { a, err := readArgs("propose", m, "kind", "number", "title", "topic", "date", "deciders", "clauses", "sections", - "links", "homes", "needs-operator", "status", "insight", "why") + "links", "homes", "needs-operator", "brief", "status", "insight", "why") if err != nil { return nil, err } @@ -977,6 +983,12 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool { if err := decode(m["links"], &p.Links, "links is a list of {kind, to, from}"); err != nil { return nil, err } + if m["brief"] != nil { + p.Brief = &Brief{} + if err := decode(m["brief"], p.Brief, "brief is {headline, summary, points}"); err != nil { + return nil, err + } + } return k.Propose(caller(m), p) }}, {Name: Seat + ".import", diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/operator.go b/modules/mesh-knowledge/cmd/mesh-knowledge/operator.go index bc0df747..b130cc53 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/operator.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/operator.go @@ -84,9 +84,48 @@ func fingerprint(digest string) string { 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. +// 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 @@ -111,9 +150,15 @@ func composeAsk(r *Record, a *OperatorAsk) asks.Ask { } } fmt.Fprintf(&b, "\n\nRead it whole with the knowledge base's get, number %d; its fingerprint is %s.", r.Number, fingerprint(r.Digest())) - out := asks.Ask{ID: a.ID, Headline: headline, Explanation: b.String(), Who: asks.Operator, - Expires: a.At.Add(AskLasts), OnExpiry: "the record stays proposed; nothing of it is in force", - About: "knowledge." + r.Kind + "." + strconv.Itoa(r.Number)} + 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"}, @@ -157,7 +202,7 @@ func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string, 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} + 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)) diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/record.go b/modules/mesh-knowledge/cmd/mesh-knowledge/record.go index 24d91e50..f364e5b0 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/record.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/record.go @@ -248,6 +248,9 @@ type OperatorAsk struct { ID string `json:"id"` At time.Time `json:"at"` Version int `json:"version"` + // Form is how the ask was composed, so it is composed again the same way to hold the warrant to it: 0 for + // an ask made before the brief (the clause leads listed), AskGlance for one read at a glance (issue 639). + Form int `json:"form,omitempty"` // Said is what came of it: waiting, approved, declined, expired, refused (and why). Said string `json:"said"` Warrant string `json:"warrant,omitempty"` @@ -291,11 +294,14 @@ type Record struct { // written; what is one is a touches link. Touches []string `json:"touches,omitempty"` // Effort is the status a proposed research effort takes when it is accepted. - Effort string `json:"effort,omitempty"` - NeedsOperator bool `json:"needs-operator,omitempty"` - Clauses []Clause `json:"clauses,omitempty"` - Links []Link `json:"links,omitempty"` - Sections []Section `json:"sections,omitempty"` + Effort string `json:"effort,omitempty"` + NeedsOperator bool `json:"needs-operator,omitempty"` + // Brief is what the operator's phone shows when the record asks for their Approve: written by its author, + // in the operator's words (novox/hq issue 639). Required of a record that needs the operator (the rule brief). + Brief *Brief `json:"brief,omitempty"` + Clauses []Clause `json:"clauses,omitempty"` + Links []Link `json:"links,omitempty"` + Sections []Section `json:"sections,omitempty"` // Homes are the documents of hq that cite the record (a design, a playbook), while those live in hq. Homes []string `json:"homes,omitempty"` // Extra holds the fields of an imported record this slice has no field of its own for, as written. @@ -329,6 +335,25 @@ type Insight struct { By string `json:"by,omitempty"` } +// Brief is a record as the operator is asked about it, on a phone, at a glance (novox/hq issue 639): a headline +// that says what the record changes for the operator — not its title — a summary of two or three short +// sentences on what changes, why, and what it costs or risks, and at most three points, only where they add +// something. The clauses are never listed in the ask; it says how to read them. +type Brief struct { + Headline string `json:"headline"` + Summary string `json:"summary"` + Points []string `json:"points,omitempty"` +} + +// Explanation is the brief as the ask explains it: the summary, then a line for each point. +func (b Brief) Explanation() string { + out := strings.TrimSpace(b.Summary) + for _, p := range b.Points { + out += "\n• " + strings.TrimSpace(p) + } + return out +} + // PendingState marks a version that is a proposal over an accepted record: a progressive insight waiting for // its review. The accepted version stays the one served until it is accepted. type PendingState struct { @@ -442,6 +467,12 @@ func Render(r *Record) string { if r.NeedsOperator { field("needs operator", "yes") } + if r.Brief != nil { + field("brief", r.Brief.Headline) + for _, l := range strings.Split(r.Brief.Explanation(), "\n") { + field("brief", l) + } + } field("homes", strings.Join(r.Homes, ", ")) keys := make([]string, 0, len(r.Extra)) for k := range r.Extra { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/review_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/review_test.go index 3e1a66dd..2590512a 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/review_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/review_test.go @@ -18,6 +18,10 @@ func proposed(t *testing.T, w *world, author string, needsOperator bool) int { n := w.reserved(t, author) p := valid(n) p.NeedsOperator = needsOperator + if needsOperator { + p.Brief = &Brief{Headline: "Merges land after ninety seconds of quiet", + Summary: "A merge window closes after ninety seconds without a merge, so merges land sooner."} + } if _, err := w.k.Propose(author, p); err != nil { t.Fatal(err) } @@ -245,7 +249,8 @@ func TestARecordThatNeedsTheOperatorIsAcceptedOnlyOnTheWarrant(t *testing.T) { } ask := w.router.asked[0] if err := ask.Check(w.clock); err != nil || ask.Options[0].Level != asks.Approve || ask.Options[1].Level != asks.Approve || - !strings.Contains(ask.Explanation, "the knowledge base's own review") { + !strings.Contains(ask.Details, "the knowledge base's own review") || ask.Headline != "Merges land after ninety seconds of quiet" || + asks.Glance(ask.Explanation) != nil || strings.Contains(ask.Explanation, "The window closes") { t.Fatalf("the ask: %v %+v", err, ask) } // Without the warrant: nothing is accepted, however often the router's record is read or the reviews run. diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go b/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go index 87e72f3c..3f657d63 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go @@ -2,9 +2,10 @@ package main // The rules a record is held to (novox/hq ADR 0290 rule 4, to-be 55 §4 and §5). // -// **29 rules judge one record or its prose**, and `propose` refuses a record that breaks any of them, naming every +// **30 rules judge one record or its prose**, and `propose` refuses a record that breaks any of them, naming every // one it breaks at once. They are hq's checks (00-META/checks/records.py, cycle.py, index.py, words.py) as they -// judge one record, moved to the write: 25 on its structure, 4 on its prose. Each has an id, said in the refusal, +// judge one record, moved to the write: 25 on its structure, 4 on its prose; and the brief a record that needs +// the operator is asked with (novox/hq issue 639). Each has an id, said in the refusal, // and one case in the table test (TestEveryPerRecordRuleRefusesAProposal). // // **4 rules judge the whole record set** (wholeSetRules) and run after every acceptance, and in the check @@ -15,6 +16,8 @@ import ( "fmt" "regexp" "strings" + + "git.novox.be/novox/mesh-sdk/go/asks" ) // Rule is one rule that judges one record: its id and what it holds. @@ -25,7 +28,7 @@ type Rule struct { From string } -// PerRecordRules are the 29, in the order they are judged. +// PerRecordRules are the 30, in the order they are judged. var PerRecordRules = []Rule{ {"kind", "a record is a decision or a research effort", "cycle.py"}, {"reserved", "its number was reserved for its kind and holds no other record", "cycle.py (duplicate numbers)"}, @@ -56,6 +59,7 @@ var PerRecordRules = []Rule{ {"daemon-line", "it writes no daemon's line", "words.py lines"}, {"deferral", "a paragraph that puts work off names the issue that tracks it", "cycle.py deferrals"}, {"insight-form", "a progressive insight its text mentions is in the dated, marked form", "records.py insights"}, + {"brief", "a record that needs the operator carries a brief its ask reads at a glance: a headline, a summary, at most three points, each fit for the phone", "novox/hq issue 639"}, } // Refusal is one rule a record breaks, and how. @@ -116,7 +120,7 @@ func allText(r *Record) string { return b.String() } -// JudgeRecord holds a proposed record to the 29 rules. held is the entry its number holds (a reservation, the +// JudgeRecord holds a proposed record to the 30 rules. held is the entry its number holds (a reservation, the // proposal being revised, or the accepted record an insight corrects); lookup reads every other record. func JudgeRecord(r *Record, held *Entry, lookup Lookup, ref *Reference) []Refusal { var out []Refusal @@ -349,6 +353,11 @@ func JudgeRecord(r *Record, held *Entry, lookup Lookup, ref *Reference) []Refusa refuse("insight-form", "a progressive insight dated %s predates the record (%s)", mk[1], r.Date) } } + if r.NeedsOperator || r.Brief != nil { + for _, why := range briefProblems(r.Brief) { + refuse("brief", "%s", why) + } + } return out } @@ -597,6 +606,7 @@ var RuleFrom = map[string]string{ "consequences": "2026-10-11", "checked": "2026-10-11", "live-citation": "2026-10-11", + "brief": "2026-10-13", } // DecisionFrom overrides RuleFrom for a decision record: hq's words check never read 02-DECISIONS ("a decision @@ -615,3 +625,35 @@ func Holds(rule, kind, date string) bool { } return !dated || !isDay.MatchString(date) || date >= from } + +// briefProblems are the ways a brief would not read at a glance on the operator's phone (novox/hq issue 639): +// missing; a headline empty or longer than an ask's; a summary or a point the router could refuse, or that +// names a path, an address or a long word (shown); more than three points, or one too long (asks.Glance). +func briefProblems(b *Brief) []string { + if b == nil { + return []string{"a record that needs the operator carries a brief (headline, summary, points): what it changes for the operator, " + + "in their words, which its ask shows in place of its clauses"} + } + var out []string + h := strings.TrimSpace(b.Headline) + if h == "" || strings.Contains(h, "\n") || len([]rune(h)) > asks.HeadlineLength { + out = append(out, fmt.Sprintf("the brief's headline is empty, more than one line, or longer than %d characters", asks.HeadlineLength)) + } + if s := strings.TrimSpace(b.Summary); s == "" || strings.Contains(s, "\n") { + out = append(out, "the brief's summary is empty or more than one line") + } + for i, p := range b.Points { + if p = strings.TrimSpace(p); p == "" || strings.Contains(p, "\n") { + out = append(out, fmt.Sprintf("the brief's point %d is empty or more than one line", i+1)) + } + } + if err := asks.Glance(b.Explanation()); err != nil { + out = append(out, "the brief "+strings.TrimPrefix(err.Error(), "the explanation ")) + } + for _, line := range append([]string{h}, strings.Split(b.Explanation(), "\n")...) { + if !shown(line) { + out = append(out, fmt.Sprintf("the brief's line %q names a path, an address, a dotted name or a long word, which the operator's phone is not shown", line)) + } + } + return out +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/rules_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/rules_test.go index 6bdbb564..e07634ea 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/rules_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/rules_test.go @@ -90,6 +90,9 @@ func TestEveryPerRecordRuleRefusesAProposal(t *testing.T) { {"insight-form", func(p *ProposeAsk, w *world, t *testing.T) { p.Sections[2].Text = "A progressive insight corrected the count." }}, + {"brief", func(p *ProposeAsk, w *world, t *testing.T) { + p.Date, p.NeedsOperator = "2026-10-13", true + }}, } covered := map[string]bool{} for _, c := range cases { @@ -100,8 +103,8 @@ func TestEveryPerRecordRuleRefusesAProposal(t *testing.T) { t.Errorf("the rule %s has no case", r.ID) } } - if len(PerRecordRules) != 29 { - t.Errorf("%d per-record rules; ADR 0290 counts 29", len(PerRecordRules)) + if len(PerRecordRules) != 30 { + t.Errorf("%d per-record rules; ADR 0290 counts 29, and issue 639 adds the brief", len(PerRecordRules)) } for _, c := range cases { t.Run(c.rule, func(t *testing.T) { diff --git a/modules/mesh-knowledge/go.mod b/modules/mesh-knowledge/go.mod index 29635555..5e41d704 100644 --- a/modules/mesh-knowledge/go.mod +++ b/modules/mesh-knowledge/go.mod @@ -3,7 +3,7 @@ module mesh-knowledge go 1.25.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011091942-4cf45b5ca0cf + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 github.com/jackc/pgx/v5 v5.11.0 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.51.0 diff --git a/modules/mesh-knowledge/go.sum b/modules/mesh-knowledge/go.sum index d21d5726..06f07d11 100644 --- a/modules/mesh-knowledge/go.sum +++ b/modules/mesh-knowledge/go.sum @@ -1,5 +1,7 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011091942-4cf45b5ca0cf h1:ED/+RszKwm86U7H/NZRbS4b5LzQ8n7rubqKmeFqFtvM= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011091942-4cf45b5ca0cf/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/modules/messenger/README.md b/modules/messenger/README.md index e56b15d3..0ead976d 100644 --- a/modules/messenger/README.md +++ b/modules/messenger/README.md @@ -129,8 +129,21 @@ operator (urgent at its bound, or its words now say "Needs you:"). An urgent con **A message offers the answers of the ask about it, and Details** (`cmd/messenger/answers.go`). The router calls no verb of a condition's: the controller asks for the answers a condition names, and performs the one -chosen on its warrant. Details is a ticket per kind, good for a day, used once, shown where it was chosen. A -warning kept quiet is also said once after a day. +chosen on its warrant. Details is a ticket per kind, good for a day, used once, shown where it was chosen, on a +condition's message. A warning kept quiet is also said once after a day. + +**An ask's message reads at a glance** (hq issue 639). Its title is the asker's headline and its body the asker's +explanation alone — a summary, then at most three points (`asks.Glance`; an ask that does not hold to it is shown +and said in the log). Everything else is the message's note (`asks.Message.Note`), which a channel shows apart and +smaller: who asked and when, with what happens if nobody answers; what each answer does; where else it is answered; +and the asker's Details (who proposed it, a fingerprint, how to read it whole). Answered, the message is one line — +"You chose Approve at 16:06 on Telegram · " — and the router's word on the asker is its note. + +**An ask's values are shown whole where the sender is proven** (hq issue 383). An ask may carry its explanation +twice: as it is held to the whole content rule, and whole (`asks.Ask.Whole`), its paths and addresses as they +are. The whole is shown only on a kind the controller issued as `verified-sender` and that was given the ask's +answers; everywhere else, and for every condition, the explanation under the content rule stands. A secret's +shape leaves the mesh nowhere: an ask whose whole words carry one is refused as a whole (`CheckSecret`). | When | What | |---|---| diff --git a/modules/messenger/cmd/messenger/answers_test.go b/modules/messenger/cmd/messenger/answers_test.go index 459a230c..828629cc 100644 --- a/modules/messenger/cmd/messenger/answers_test.go +++ b/modules/messenger/cmd/messenger/answers_test.go @@ -367,8 +367,8 @@ func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) { } // The question's title is how it ended, read at a glance; then what the router knows of the asker (issue 372). if len(edited.Actions) != 0 || edited.Title != "You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours" || - !strings.HasPrefix(edited.Body, "Told to mesh-delivery, which acts on it itself.\n") { - t.Errorf("%s still offers %q, or does not say how it ended: %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body) + edited.Body != "" || edited.Note != "Told to mesh-delivery, which acts on it itself." { + t.Errorf("%s still offers %q, or does not say how it ended in one line (issue 639): %q / %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body, edited.Note) } } // The tap is confirmed where it was made, in reply to it, once the warrant stood — and before the question @@ -625,7 +625,7 @@ func TestAnAskExpiresAndNeverDefaults(t *testing.T) { edited = m } if len(edited.Actions) != 0 || !strings.HasPrefix(edited.Title, "No longer answerable: nobody answered in time · Delivery of hq") || - !strings.HasPrefix(edited.Body, "The delivery stays held.") { + edited.Body != "" || edited.Note != "The delivery stays held." { t.Errorf("the copy still offers answers, or does not say how it ended: %+v", edited) } } diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index 33e7459a..04ac5d04 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -163,6 +163,33 @@ func (h *Holder) openAsk(r *Record) *AskRecord { return &rec } +// wholeOf is an ask's explanation with its values whole (asks.Ask.Whole), where an ask's own message on one +// kind shows it in place of the explanation (novox/hq issue 383): on a kind that proves who answers by the +// controller's fresh word (Promises, as every other verified-sender judgement here), and that the router gave +// the ask's answers (a ticket there, so the kind was linked and root-free when the ask was taken). Empty where +// the kind proves nothing, the ask has no whole words, or the record is a condition's — a condition's words +// stay under the whole content rule, whatever an ask about it carries — then the explanation, held to that +// rule, is shown. The ask's state does not matter: a message edited to say how the ask ended still shows what +// was approved. +func (h *Holder) wholeOf(r *Record, kind string) string { + if r == nil || r.Origin != "ask" || r.Ask == "" || kind == "" || h.Bench == nil { + return "" + } + e := h.askOf(r.Ask) + if e == nil || e.rec.Ask.Whole == "" { + return "" + } + if !h.Bench.Promises(kind, "verified-sender") { + return "" + } + for _, tk := range e.rec.Tickets { + if tk.Kind == kind { + return e.rec.Ask.Whole + } + } + return "" +} + // carriers are the kinds that can carry an answer of a level now: ready, offering a choice, and for // approve verifying their sender with an account of the operator's linked, where the controller says now // that no agent can become root (rootFree, asked live). @@ -439,6 +466,18 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error { h.refuseAsk(asker, a, "its words carry "+refusal.String()+", which may not leave the mesh: move it into a reference or say it in words") return nil } + // An ask that does not read at a glance is said, and shown all the same: the bound is its asker's to keep + // (asks.Glance, issue 639), and refusing it would leave the operator unasked. + if err := asks.Glance(a.Explanation); err != nil { + h.Logf("[messenger] %s's ask %q is a wall of text on the phone: %v", asker, a.Headline, err) + } + // The whole words are shown only where the sender is proven (wholeOf), so a path or an address may stand + // in them; a secret's shape leaves the mesh nowhere, and an ask carrying one is refused as a whole, never + // shown with its explanation alone (novox/hq issue 383). + if refusal, ok := CheckSecret(a.Whole); !ok { + h.refuseAsk(asker, a, "its whole words carry "+refusal.String()+", which may not leave the mesh on any channel: withhold it") + return nil + } open, replaced := 0, "" h.mu.Lock() for k, e := range h.asks { @@ -1040,7 +1079,7 @@ func (h *Holder) showOnHandle(kind string, m Message) (string, error) { if h.Publish == nil { return "", errors.New("this router cannot reach the bus") } - out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, To: m.To, + out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, To: m.To, Secret: m.Secret} if out.To == "" && h.Bench != nil && h.Bench.To != nil { out.To = h.Bench.To(kind) @@ -1065,7 +1104,7 @@ func (h *Holder) showOn(kind string, m Message) error { if h.Publish == nil { return errors.New("this router cannot reach the bus") } - out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply} + out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply} if h.Bench != nil && h.Bench.To != nil { out.To = h.Bench.To(kind) } diff --git a/modules/messenger/cmd/messenger/content.go b/modules/messenger/cmd/messenger/content.go index f5b8de5f..2c0b5552 100644 --- a/modules/messenger/cmd/messenger/content.go +++ b/modules/messenger/cmd/messenger/content.go @@ -65,6 +65,59 @@ func Check(text string) (Refusal, bool) { return Refusal{"address", "a hardware address"}, false case reIPv6.MatchString(text): return Refusal{"address", "an IPv6 address"}, false + } + if refusal, ok := secretShape(text); !ok { + return refusal, false + } + if reWinPath.MatchString(text) { + return Refusal{"path", "a drive path"}, false + } + if refusal, ok := randomRun(text); !ok { + return refusal, false + } + for _, word := range strings.FieldsFunc(text, func(r rune) bool { + return unicode.IsSpace(r) || strings.ContainsRune("\"'`()[]{}<>,;|", r) + }) { + w := strings.TrimRight(word, ".:!?") + if isPath(w) { + return Refusal{"path", "a file path"}, false + } + if isHostName(w) { + return Refusal{"address", "a host name"}, false + } + } + return Refusal{}, true +} + +// CheckSecret says whether a text carries a secret's shape — the one class that leaves the mesh nowhere. It is +// what an ask's whole words (asks.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers, +// a path or an address is what the operator approves and may be shown, a secret never. A path is read as one: +// a run with a slash in it is judged piece by piece between the slashes, so "/mnt/Photos_2024/Jochen" is a +// path and not a random string (the review of 2026-10-10); the named shapes — a key block, a signed token, a +// bot token, a known token shape, a value given to a secret's name, a long hexadecimal string — hold whatever +// the run holds. +func CheckSecret(text string) (Refusal, bool) { + if refusal, ok := secretShape(text); !ok { + return refusal, false + } + return randomRunOutsidePaths(text) +} + +// randomRunOutsidePaths is randomRun with each run that holds a slash judged by its pieces between the slashes. +func randomRunOutsidePaths(text string) (Refusal, bool) { + for _, run := range reRun.FindAllString(text, -1) { + for _, piece := range strings.Split(run, "/") { + if len(piece) >= 20 && looksRandom(piece) { + return Refusal{"secret", "a long random-looking string"}, false + } + } + } + return Refusal{}, true +} + +// secretShape is the secret shapes a pattern names. +func secretShape(text string) (Refusal, bool) { + switch { case rePEM.MatchString(text): return Refusal{"secret", "a key block"}, false case reJWT.MatchString(text): @@ -77,25 +130,17 @@ func Check(text string) (Refusal, bool) { return Refusal{"secret", "a value given to a secret's name"}, false case reHex.MatchString(text): return Refusal{"secret", "a long hexadecimal string"}, false - case reWinPath.MatchString(text): - return Refusal{"path", "a drive path"}, false } + return Refusal{}, true +} + +// randomRun is a long unbroken run of characters spread as a random string's are. +func randomRun(text string) (Refusal, bool) { for _, run := range reRun.FindAllString(text, -1) { if looksRandom(run) { return Refusal{"secret", "a long random-looking string"}, false } } - for _, word := range strings.FieldsFunc(text, func(r rune) bool { - return unicode.IsSpace(r) || strings.ContainsRune("\"'`()[]{}<>,;|", r) - }) { - w := strings.TrimRight(word, ".:!?") - if isPath(w) { - return Refusal{"path", "a file path"}, false - } - if isHostName(w) { - return Refusal{"address", "a host name"}, false - } - } return Refusal{}, true } diff --git a/modules/messenger/cmd/messenger/feedback_test.go b/modules/messenger/cmd/messenger/feedback_test.go index 044db8dd..fc7c8859 100644 --- a/modules/messenger/cmd/messenger/feedback_test.go +++ b/modules/messenger/cmd/messenger/feedback_test.go @@ -122,8 +122,8 @@ func TestTheConfirmationNeverClaimsWhatDidNotHappen(t *testing.T) { for _, m := range tg.edits { edited = m } - if !strings.HasPrefix(edited.Body, "Told to mesh-delivery as soon as the bus takes it") { - t.Errorf("the question claims its asker was told: %q", edited.Body) + if !strings.HasPrefix(edited.Note, "Told to mesh-delivery as soon as the bus takes it") { + t.Errorf("the question claims its asker was told: %q", edited.Note) } }) } diff --git a/modules/messenger/cmd/messenger/glance_test.go b/modules/messenger/cmd/messenger/glance_test.go new file mode 100644 index 00000000..669f38a3 --- /dev/null +++ b/modules/messenger/cmd/messenger/glance_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// novox/hq issue 639: an ask's message reads at a glance on the phone. Its body is the asker's summary and points +// alone; who asked and when, what each answer does, the expiry and the asker's Details are its note; once answered +// it is one line with the headline. + +// decisionAsk is the ask for ADR 0343 as the knowledge base composes it now (its glance form). +func decisionAsk(c *clock) asks.Ask { + return asks.Ask{ID: "kb-decision-343-0d1e2f3a", Headline: "Your agents' memory follows you to every machine", + Explanation: "What an agent learns in a project's memory is copied to every machine that runs the agent, so a session " + + "anywhere starts with it. A file changed on two machines keeps both versions and warns you; secrets in memory files " + + "are screened, though not perfectly.\n" + + "• Only memory folders travel: links and files over 128 KiB stay where they are\n" + + "• A conflict reaches you as a warning that needs you\n" + + "• A deleted file is marked, and a newer change elsewhere outlives the deletion", + Who: asks.Operator, Expires: c.now().Add(23*time.Hour + 50*time.Minute), OnExpiry: "the record stays proposed; nothing of it is in force", + About: "knowledge.decision.343", + Details: "ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.\n" + + "Read its 13 clauses with the knowledge base's get, number 343.", + Options: []asks.Option{ + {ID: "approve", Label: "Approve", Does: "the record is accepted and its clauses are in force", Level: asks.Approve, Binds: bound("approve")}, + {ID: "decline", Label: "Decline", Does: "the record stays proposed; nothing of it is in force", Level: asks.Approve, Binds: bound("decline")}, + }} +} + +func TestAnAsksMessageIsItsSummaryWithTheRestInItsNote(t *testing.T) { + h, tg, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + a := decisionAsk(c) + submit(t, h, "mesh-knowledge", a) + settle(h, c) + if len(tg.sends) != 1 { + t.Fatalf("telegram got %d messages", len(tg.sends)) + } + m := tg.sends[0] + if m.Title != a.Headline || m.Body != a.Explanation { + t.Errorf("the body is not the asker's summary and points alone:\n%s\n%s", m.Title, m.Body) + } + note := strings.Split(m.Note, "\n") + asked := c.now().In(time.UTC).Format("15:04") + want := []string{ + "Asked by mesh-knowledge at " + asked + ". Unanswered by tomorrow " + a.Expires.In(time.UTC).Format("15:04") + + ": the record stays proposed; nothing of it is in force.", + "Approve: the record is accepted and its clauses are in force. Decline: the record stays proposed; nothing of it is in force.", + "ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.", + "Read its 13 clauses with the knowledge base's get, number 343.", + } + if strings.Join(note, "\n") != strings.Join(want, "\n") { + t.Errorf("the note:\n%s\nwant:\n%s", m.Note, strings.Join(want, "\n")) + } + if n := len(strings.Split(m.Text(), "\n")); n > 9 || len(m.Actions) != 2 { + t.Errorf("the message is %d lines offering %s:\n%s", n, labelsOf(m), m.Text()) + } + // Answered, it is one line with the headline; the body is not said again. + if out, err := h.Chosen("telegram", choiceOf(ticketFor(m, "Approve"), "42", c.now())); err != nil || out != "chosen" { + t.Fatalf("Approve: %q %v", out, err) + } + var edited Message + for _, e := range tg.edits { + edited = e + } + if edited.Title != "You chose Approve at "+asked+" on Telegram · "+a.Headline || edited.Body != "" || + strings.Contains(edited.Note, "What an agent learns") || len(edited.Actions) != 0 { + t.Errorf("the answered copy: %q\n%q\n%q", edited.Title, edited.Body, edited.Note) + } +} + +// An ask that does not read at a glance is shown all the same, and said: refusing it would leave the operator +// unasked, and the bound is its asker's to keep. +func TestAnAskThatIsAWallOfTextIsShownAndSaid(t *testing.T) { + h, tg, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + var said []string + h.Logf = func(f string, a ...any) { said = append(said, f) } + a := decisionAsk(c) + a.Explanation += "\nA fourth point\nA fifth point" + submit(t, h, "mesh-knowledge", a) + settle(h, c) + if len(tg.sends) != 1 || !strings.Contains(strings.Join(said, "\n"), "is a wall of text on the phone") { + t.Errorf("sent %d, said %v", len(tg.sends), said) + } +} diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 55bdd51d..8b3f448a 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -90,8 +90,11 @@ const ( // Message is what a channel shows: a title line and a body, and the answers it offers. type Message struct { - Title string - Body string + Title string + Body string + // Note is the secondary words, after the body and the answers: who asked, what each answer does, the + // expiry, the asker's Details (asks.Message.Note, issue 639). + Note string Urgent bool Quiet bool // a clearing: shown without urgency Actions []Choice @@ -111,10 +114,13 @@ type Choice struct { } func (m Message) Text() string { - if m.Body == "" { - return m.Title + t := m.Title + for _, more := range []string{m.Body, m.Note} { + if more != "" { + t += "\n" + more + } } - return m.Title + "\n" + m.Body + return t } // Channel is one way to the operator. @@ -839,6 +845,16 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message { if what == "cleared" || what == "silenced" || m.Title == "A mesh message was withheld" { return m } + // On a kind that proves who answers, an ask's explanation is shown with its values whole (issue 383): the + // path or the address the operator approves is readable there; a secret's shape still is not. + whole := h.wholeOf(r, kind) + if whole != "" { + if wm, ok := h.wholeWords(r, what, whole); ok { + m = wm + } else { + whole = "" + } + } if r.Answered != "" { m = h.answeredWords(r, m) } @@ -847,11 +863,13 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message { } withheld := map[string]bool{} // the labels of answers whose effect could not be said if rec := h.openAsk(r); rec != nil { - // Who asks, as the bus proves it (the asker is the subject's, never the body's), what each answer does, - // and what happens if nobody answers (the review of 2026-10-09). - for _, line := range h.askLines(rec) { + // The note (issue 639): who asks, as the bus proves it (the asker is the subject's, never the body's), what + // each answer does, what happens if nobody answers (the review of 2026-10-09), where else it is answered, + // and the asker's Details. The body stays the asker's summary and points, read at a glance. + var note []string + for _, line := range h.askLines(rec, r) { if _, ok := Check(line); ok { - m.Body = strings.TrimSpace(m.Body + "\n" + line) + note = append(note, line) } } // **An answer whose effect is not said is not offered** (the confirmation review of 2026-10-09): its line @@ -864,9 +882,19 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message { } if line := h.elsewhere(rec, kind); line != "" { if _, ok := Check(line); ok { - m.Body = strings.TrimSpace(m.Body + "\n" + line) + note = append(note, line) } } + for _, line := range strings.Split(strings.TrimSpace(rec.Ask.Details), "\n") { + if line = strings.TrimSpace(line); line == "" { + continue + } + if refusal, ok := Check(line); !ok { + line = "(a line carrying " + refusal.What + " is withheld here)" + } + note = append(note, line) + } + m.Note = strings.Join(note, "\n") } for _, c := range h.choices(r, kind) { if !withheld[c.Label] { @@ -877,38 +905,58 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message { } // answeredWords is a message that says what came of its question. An ask's own message is the question -// itself, so its title becomes how it ended — "You chose Approve at 13:30 on Telegram · " — read at -// a glance on the phone, where the edit rings nothing (issue 372); the rest of what came of it, the -// explanation and when it was asked follow. A condition's message keeps its own title, and says it first. +// itself, so it becomes how it ended and what it was about — "You chose Approve at 13:30 on Telegram · +// " — one line, read at a glance on the phone, where the edit rings nothing (issue 372); the body +// is not said again (issue 639), and what the router knows of the asker after it is the note. A condition's +// message keeps its own title, and says it first. func (h *Holder) answeredWords(r *Record, m Message) Message { if r.Origin == "ask" { first, rest, _ := strings.Cut(r.Answered, "\n") - headline, explanation := wordsOf(r) - out := Message{Title: strings.TrimSuffix(first, ".") + " · " + headline, Urgent: m.Urgent, Quiet: m.Quiet, - Body: strings.TrimSpace(rest + "\n" + explanation + "\nAsked " + when(r.Raised, h.Now(), h.zone()) + ".")} + headline, _ := wordsOf(r) + out := Message{Title: strings.TrimSuffix(first, ".") + " · " + headline, Note: strings.TrimSpace(rest), + Urgent: m.Urgent, Quiet: m.Quiet} if _, ok := Check(out.Text()); ok { return out } - // Its words refused (they are the asker's): the outcome is still said, before what may leave. + // Its headline refused (it is the asker's): the outcome is still said, before what may leave. } m.Body = strings.TrimSpace(r.Answered + "\n" + m.Body) return m } -// askLines are what an ask's message says of the ask itself: who asks, each option's effect, and its expiry. -func (h *Holder) askLines(rec *AskRecord) []string { +// wholeWords is a record's message with the ask's whole words in place of its explanation, when they carry no +// secret's shape once composed; else false, and the message under the whole content rule stands. +func (h *Holder) wholeWords(r *Record, what, whole string) (Message, bool) { + c := *r + c.Explanation = whole + m := compose(&c, what, h.Now(), h.zone(), 0) + if refusal, ok := CheckSecret(m.Text()); !ok { + h.Logf("[messenger] the whole words of %s carry %s; shown as on a channel that proves nothing", r.Key, refusal) + return Message{}, false + } + return m, true +} + +// askLines are what an ask's message notes of the ask itself, in two lines: who asks and when, with what happens +// if nobody answers; then each option's effect. +func (h *Holder) askLines(rec *AskRecord, r *Record) []string { asker := rec.Asker if asker == SelfAsker { asker = "the router itself" } - lines := []string{"Asked by " + asker + "."} + first := "Asked by " + asker + " at " + when(r.Raised, h.Now(), h.zone()) + "." + if !rec.Ask.Expires.IsZero() && rec.Ask.OnExpiry != "" { + first += " Unanswered by " + by(rec.Ask.Expires, h.Now(), h.zone()) + ": " + strings.TrimSuffix(rec.Ask.OnExpiry, ".") + "." + } + lines := []string{first} + var does []string for _, o := range rec.Ask.Options { if o.Does != "" { - lines = append(lines, o.Label+": "+o.Does+".") + does = append(does, o.Label+": "+strings.TrimSuffix(o.Does, ".")+".") } } - if !rec.Ask.Expires.IsZero() && rec.Ask.OnExpiry != "" { - lines = append(lines, "If nobody answers by "+by(rec.Ask.Expires, h.Now(), h.zone())+": "+rec.Ask.OnExpiry+".") + if len(does) > 0 { + lines = append(lines, strings.Join(does, " ")) } return lines } diff --git a/modules/messenger/cmd/messenger/onemessage_test.go b/modules/messenger/cmd/messenger/onemessage_test.go index a16cbb58..56d16d1c 100644 --- a/modules/messenger/cmd/messenger/onemessage_test.go +++ b/modules/messenger/cmd/messenger/onemessage_test.go @@ -158,8 +158,8 @@ func TestAnAsksExpiryIsSaidWithItsDay(t *testing.T) { rec := &AskRecord{Asker: ConditionOwner} rec.Ask.Expires = now.Add(7 * 24 * time.Hour) rec.Ask.OnExpiry = "nothing is done" - lines := strings.Join(h.askLines(rec), "\n") - if !strings.Contains(lines, "If nobody answers by Fri 16 Oct 21:33: nothing is done.") { + lines := strings.Join(h.askLines(rec, &Record{Raised: now}), "\n") + if !strings.Contains(lines, "Unanswered by Fri 16 Oct 21:33: nothing is done.") { t.Fatalf("%s", lines) } } diff --git a/modules/messenger/cmd/messenger/review_test.go b/modules/messenger/cmd/messenger/review_test.go index a5357129..3409c3a6 100644 --- a/modules/messenger/cmd/messenger/review_test.go +++ b/modules/messenger/cmd/messenger/review_test.go @@ -208,11 +208,12 @@ func TestAnAsksMessageSaysWhoAsksWhatEachAnswerDoesAndWhatExpiryDoes(t *testing. link(h, store, c, "42", c.now().Add(-2*time.Hour)) submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) settle(h, c) - body := tg.sends[0].Body - for _, want := range []string{"Asked by mesh-delivery.", "Release: the delivery goes on.", "Stop: the delivery ends.", + // In the note, after the body and the buttons (issue 639): the body is the asker's own words alone. + body := tg.sends[0].Note + for _, want := range []string{"Asked by mesh-delivery at ", "Release: the delivery goes on. Stop: the delivery ends.", "Silence for a week: nothing more is said of it for a week.", - "If nobody answers by " + c.now().Add(2*time.Hour-BurstWindow).In(time.UTC).Format("15:04") + ": the delivery stays held."} { - if !strings.Contains(body, want) { + "Unanswered by " + c.now().Add(2*time.Hour-BurstWindow).In(time.UTC).Format("15:04") + ": the delivery stays held."} { + if !strings.Contains(body, want) || strings.Contains(tg.sends[0].Body, want) { t.Errorf("the message does not say %q:\n%s", want, body) } } diff --git a/modules/messenger/cmd/messenger/seatchannel.go b/modules/messenger/cmd/messenger/seatchannel.go index d05ca6f6..671b892a 100644 --- a/modules/messenger/cmd/messenger/seatchannel.go +++ b/modules/messenger/cmd/messenger/seatchannel.go @@ -286,7 +286,7 @@ func (c *SeatChannel) putOn(kind, handle string, m Message, edit bool) error { } // Secret travels whatever the path: a link's code is kept by no channel (the review of 2026-10-09 found it // dropped here, where the desk's own role sends). - out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, + out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, Secret: m.Secret} if c.Bench.To != nil { out.To = c.Bench.To(kind) diff --git a/modules/messenger/cmd/messenger/whole_test.go b/modules/messenger/cmd/messenger/whole_test.go new file mode 100644 index 00000000..a094cfe2 --- /dev/null +++ b/modules/messenger/cmd/messenger/whole_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// novox/hq issue 383: a settings proposal's values are what the operator approves, so where the sender is proven +// they are shown whole; where nothing is proven, the explanation under the whole content rule stands. + +// proposalAsk is a proposal as the controller composes it: the explanation with its values masked, the whole +// with them whole, and Details with the fingerprint and where to read it, which the note shows. +func proposalAsk(c *clock, id string) asks.Ask { + a := heldAsk(c, id) + a.Headline = "Set mounts on shanks?" + a.Options = a.Options[:2] + a.Explanation = "+ shares: media=‹path›\n+ sources: recalbox=‹address›@‹path›\nProposed by an agent at 15:11." + a.Whole = "+ shares: media=/storage/media\n+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox\nProposed by an agent at 15:11." + a.Details = "Fingerprint 1234 5678 9abc def0 1234 5678.\nRead it whole: settings proposals " + id + " at the controller's terminal." + return a +} + +func TestAnAsksWholeWordsAreShownWhereTheSenderIsProvenAndMaskedElsewhere(t *testing.T) { + h, tg, _, c, store, w := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + submit(t, h, "mesh-controller", proposalAsk(c, "s-1")) + settle(h, c) + if len(tg.sends) != 1 { + t.Fatalf("telegram got %d messages", len(tg.sends)) + } + phone := tg.sends[0] + for _, want := range []string{"+ shares: media=/storage/media", "recalbox=smb://nas.lan/recalbox@/mnt/recalbox", "Proposed by an agent at 15:11."} { + if !strings.Contains(phone.Body, want) { + t.Errorf("the phone, which proves who answers, lacks %q:\n%s", want, phone.Body) + } + } + if strings.Contains(phone.Body, "‹") || strings.Contains(phone.Body, "Fingerprint") || strings.Contains(phone.Body, "settings proposals") { + t.Errorf("the phone shows a masked value, or the Details' words, in the body:\n%s", phone.Body) + } + // The Details are the note's, after what the answers do (issue 639): no answer of their own. + if !strings.Contains(phone.Note, "Fingerprint 1234 5678 9abc def0 1234 5678.") || + !strings.Contains(phone.Note, "Read it whole: settings proposals s-1 at the controller's terminal.") { + t.Errorf("the phone's note lacks the Details:\n%s", phone.Note) + } + if phone.Title != "Set mounts on shanks?" || ticketFor(phone, "Release") == "" || ticketFor(phone, DetailsLabel) != "" { + t.Errorf("the phone's message: %q offering %s", phone.Title, labelsOf(phone)) + } + // The desk proves nothing: the same record composed for it shows the explanation, its values masked. + r := h.open["ask.mesh-controller.s-1"] + if r == nil { + t.Fatal("no record of the ask") + } + desk := h.sayOn(r, "raised", "desktop") + if !strings.Contains(desk.Body, "+ shares: media=‹path›") || strings.Contains(desk.Text(), "/storage/media") || strings.Contains(desk.Text(), "nas.lan") { + t.Errorf("the desk shows a value whole:\n%s", desk.Text()) + } + if got := w.of("mesh-controller"); len(got) != 0 || store.asks["mesh-controller.s-1"].State != StateOpen { + t.Errorf("the ask was answered: %+v / %s", got, store.asks["mesh-controller.s-1"].State) + } + // Approved, the message is edited to one line that says so, with the headline; the body is not said again + // (issue 639). + if out, err := h.Chosen("telegram", choiceOf(ticketFor(phone, "Release"), "42", c.now())); err != nil || out != "chosen" { + t.Fatalf("Release: %q %v", out, err) + } + edited, ok := tg.edits["1"] + if !ok { + t.Fatalf("the phone's message was not edited: %+v", tg.edits) + } + if !strings.HasPrefix(edited.Title, "You chose Release at ") || !strings.HasSuffix(edited.Title, " · Set mounts on shanks?") || + edited.Body != "" || len(edited.Actions) != 0 { + t.Errorf("the edited message: %q\n%s\nactions %+v", edited.Title, edited.Body, edited.Actions) + } +} + +// The mutation on the switch: the same ask without whole words is shown on the phone as on the desk, from its +// explanation; the whole words alone make the difference, and only where the sender is proven. +func TestAnAskWithoutWholeWordsIsShownFromItsExplanationEverywhere(t *testing.T) { + h, tg, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + a := proposalAsk(c, "s-2") + a.Whole, a.Details = "", "" + submit(t, h, "mesh-controller", a) + settle(h, c) + if len(tg.sends) != 1 || !strings.Contains(tg.sends[0].Body, "+ shares: media=‹path›") || strings.Contains(tg.sends[0].Body, "/storage/media") { + t.Fatalf("telegram: %+v", tg.sends) + } + if ticketFor(tg.sends[0], DetailsLabel) != "" || strings.Contains(tg.sends[0].Note, "Fingerprint") { + t.Errorf("an ask that says nothing for Details offers or notes it: %s\n%s", labelsOf(tg.sends[0]), tg.sends[0].Note) + } + // A kind that proves nothing never shows the whole words, whatever the ask says. + a = proposalAsk(c, "s-3") + submit(t, h, "mesh-controller", a) + settle(h, c) + if whole := h.wholeOf(h.open["ask.mesh-controller.s-3"], "desktop"); whole != "" { + t.Errorf("the desk is given the whole words: %q", whole) + } + if whole := h.wholeOf(h.open["ask.mesh-controller.s-3"], "telegram"); whole != a.Whole { + t.Errorf("the phone is not given the whole words: %q", whole) + } +} + +// A secret's shape leaves the mesh nowhere: an ask whose whole words carry one is refused as a whole, so the +// phone never sees it, with or without the proven sender. +func TestAnAskWhoseWholeWordsCarryASecretIsRefused(t *testing.T) { + // A path is read as a path, whatever its case and digits; a random run outside one is still a secret. + for text, may := range map[string]bool{ + "+ shares: photos=/mnt/Photos_2024/Jochen": true, + "+ sources: games=smb://nas.lan/Recalbox_Games2024": true, + "+ library: /srv/media/Series_Archive/Season01": true, + "+ key: xK9mQ2vL8pR4tW7yB3nF6hJ1dG5sA0zC": false, + } { + if _, ok := CheckSecret(text); ok != may { + t.Errorf("CheckSecret(%q) lets it leave: %v", text, ok) + } + } + // A condition's record never shows an ask's whole words, whichever kind: its words are its own. + { + h, _, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + k := cond("delivery.hq.held", Warning, "the delivery of hq is held") + k.Raised = c.now() + h.Condition(EventRaised, k) + settle(h, c) + a := proposalAsk(c, "s-5") + a.About = "delivery.hq.held" + submit(t, h, "mesh-controller", a) + settle(h, c) + if r := h.open["delivery.hq.held"]; r == nil || r.Ask != "mesh-controller.s-5" || h.wholeOf(r, "telegram") != "" { + t.Errorf("a condition's record is given an ask's whole words") + } + } + for _, secret := range []string{ + "+ token: ghp_abcdefghijklmnopqrstuvwxyz0123456789", + "+ password=hunter2", + "+ key: 0123456789abcdef0123456789abcdef", + "-----BEGIN PRIVATE KEY-----", + } { + h, tg, _, c, store, w := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + a := proposalAsk(c, "s-4") + a.Whole = "+ shares: media=/storage/media\n" + secret + submit(t, h, "mesh-controller", a) + settle(h, c) + got := w.of("mesh-controller") + if len(tg.sends) != 0 || len(got) != 1 || got[0].Outcome != asks.OutcomeRefused || !strings.Contains(got[0].Words, "whole words carry secret") { + t.Errorf("%q: sent %d, told %+v", secret, len(tg.sends), got) + } + if _, kept := store.asks["mesh-controller.s-4"]; kept { + t.Errorf("%q: the refused ask was recorded", secret) + } + } +} diff --git a/modules/messenger/cmd/messenger/words.go b/modules/messenger/cmd/messenger/words.go index d0723de0..b3546232 100644 --- a/modules/messenger/cmd/messenger/words.go +++ b/modules/messenger/cmd/messenger/words.go @@ -76,7 +76,14 @@ func compose(r *Record, what string, now time.Time, loc *time.Location, withhold since += "." } body := since - if explanation != "" { + if r.Origin == "ask" && what != "reminder" && what != "quiet-reminder" { + // An ask's own message (issue 639): its body is the asker's summary and points; when it was asked is in + // its note, with who asked. + body = "" + } + if explanation != "" && body == "" { + body = explanation + } else if explanation != "" { body = explanation + "\n" + since } return Message{Title: title, Body: body, Urgent: urgent} diff --git a/modules/messenger/go.mod b/modules/messenger/go.mod index b00bc100..d471d086 100644 --- a/modules/messenger/go.mod +++ b/modules/messenger/go.mod @@ -2,4 +2,4 @@ module messenger go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.10 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 diff --git a/modules/messenger/go.sum b/modules/messenger/go.sum index 8446865c..75e71c63 100644 --- a/modules/messenger/go.sum +++ b/modules/messenger/go.sum @@ -1,2 +1,4 @@ -git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o= -git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.13 h1:Su4JYpZ+zhNovkGA2DgxiZdcuHpjw2tPJzc/7PljhXg= +git.novox.be/novox/mesh-sdk/go v0.1.13/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/telegram/README.md b/modules/telegram/README.md index 46598277..c12fe8eb 100644 --- a/modules/telegram/README.md +++ b/modules/telegram/README.md @@ -31,6 +31,7 @@ as the operator. | | fixed here | |---|---| | D1 | up to 4096 characters arrive whole; longer is cut and the cut said | +| issues 383, 639 | shown formatted (HTML), never reworded: the title bold, the body as it is with a key line in monospace, the note apart in a blockquote collapsed to its first lines; `<`, `>` and `&` escaped first, so nothing in the words is read as markup. A tap is compared by the plain words, whitespace aside | | D4 | a warning or a clearing arrives without sound; an urgent message or a question rings | | D5 | a rate limit is waited out as told | | D6 | a refusal is a permanent failure, said to the router and not retried; an edit to the same text is a success | diff --git a/modules/telegram/cmd/telegram/bot.go b/modules/telegram/cmd/telegram/bot.go index 757e629a..cbeb8112 100644 --- a/modules/telegram/cmd/telegram/bot.go +++ b/modules/telegram/cmd/telegram/bot.go @@ -249,9 +249,10 @@ func keyboard(buttons [][2]string) map[string]any { return map[string]any{"inline_keyboard": rows} } -// Send posts a message and answers its id. silent: it arrives without sound (D4). +// Send posts a message, its text in ParseMode (rendered or escaped by the caller), and answers its id. silent: +// it arrives without sound (D4). func (b *Bot) Send(chat int64, text string, buttons [][2]string, silent bool) (int64, error) { - body := map[string]any{"chat_id": chat, "text": text, "disable_notification": silent, + body := map[string]any{"chat_id": chat, "text": text, "parse_mode": ParseMode, "disable_notification": silent, "link_preview_options": map[string]any{"is_disabled": true}} if len(buttons) > 0 { body["reply_markup"] = keyboard(buttons) @@ -261,9 +262,10 @@ func (b *Bot) Send(chat int64, text string, buttons [][2]string, silent bool) (i return out.MessageID, err } -// Edit replaces a message's text and buttons; none given, the buttons go. +// Edit replaces a message's text (in ParseMode, rendered or escaped by the caller) and buttons; none given, the +// buttons go. func (b *Bot) Edit(chat, id int64, text string, buttons [][2]string) error { - err := b.call("editMessageText", map[string]any{"chat_id": chat, "message_id": id, "text": text, + err := b.call("editMessageText", map[string]any{"chat_id": chat, "message_id": id, "text": text, "parse_mode": ParseMode, "reply_markup": keyboard(buttons), "link_preview_options": map[string]any{"is_disabled": true}}, nil) if notModified(err) { return nil diff --git a/modules/telegram/cmd/telegram/channel.go b/modules/telegram/cmd/telegram/channel.go index 30680da8..b890185c 100644 --- a/modules/telegram/cmd/telegram/channel.go +++ b/modules/telegram/cmd/telegram/channel.go @@ -7,7 +7,9 @@ package main // // - **Work** comes from its kind's worker on the `channel` queue: `show` (a message with buttons, each // carrying only the router's ticket), `send` (words), `edit` (a message shown before, by the router's -// handle). Up to 4096 characters arrive whole; longer is cut and the cut said (D1). A warning or a +// handle). Up to 4096 characters arrive whole; longer is cut and the cut said (D1). The words are shown +// as the router gave them, formatted (render.go): the title bold, a key line in monospace, the note in a +// collapsed blockquote after the body, escaped so that none of them is read as markup (issues 383, 639). A warning or a // clearing arrives without sound, an urgent message or a question rings (D4). A rate limit is waited out // as told (D5); a refusal is a permanent failure, said to the router, never retried; an edit to the same // text is a success (D6). A message that cannot be delivered after three tries is said as failed. A @@ -88,10 +90,18 @@ type Store interface { Show(chat, id int64, digest string) error } -// wordsDigest is what a tap's message is compared by: the words as Telegram gives them back — its line -// endings and outer spaces aside — so a message whose words another holder of the token changed is told -// from the one this channel showed (the review of 2026-10-09). +// wordsDigest is what a tap's message is compared by: the words as Telegram gives them back — how the +// whitespace between them runs aside, which Telegram may change around a blockquote (issue 639) — so a message +// whose words another holder of the token changed is told from the one this channel showed (the review of +// 2026-10-09). func wordsDigest(words string) string { + sum := sha256.Sum256([]byte(strings.Join(strings.Fields(words), " "))) + return hex.EncodeToString(sum[:]) +} + +// linesDigest is the digest a message shown before issue 639 was kept under: its line endings and outer spaces +// aside. A tap on such a message is still compared by it. +func linesDigest(words string) string { w := strings.TrimSpace(strings.ReplaceAll(words, "\r\n", "\n")) sum := sha256.Sum256([]byte(w)) return hex.EncodeToString(sum[:]) @@ -155,20 +165,6 @@ func (c *Channel) init() { } } -// text is a message as the chat shows it, cut at MaxLength and the cut said (D1). -func text(m asks.Message) string { - t := m.Title - if m.Body != "" { - t += "\n" + m.Body - } - if utf8.RuneCountInString(t) <= MaxLength { - return t - } - runes := []rune(t) - keep := MaxLength - utf8.RuneCountInString(cutNote) - return string(runes[:keep]) + cutNote -} - // buttons are a message's buttons as the API takes them: a label and the ticket, which must fit the 64 // bytes Telegram allows; one that does not is left out and said. func (c *Channel) buttons(m asks.Message) [][2]string { @@ -231,7 +227,7 @@ func (c *Channel) Take(subject string, body json.RawMessage) error { c.fail(m.Handle, err, true) return nil } - words, buttons := text(m), c.buttons(m) + said, buttons := partsOf(m), c.buttons(m) silent := m.Silent || (!m.Urgent && len(buttons) == 0) // **A message said again under a handle already shown replaces it** (issue 373): a reminder or an // escalation is one message per problem, not one more beside it. An edit here notifies nobody, so this one @@ -253,15 +249,15 @@ func (c *Channel) Take(subject string, body json.RawMessage) error { var id int64 switch { case place != nil: - err = c.Bot.Edit(place.Chat, place.ID, words, buttons) + err = c.Bot.Edit(place.Chat, place.ID, said.html(), buttons) if err == nil || notModified(err) { - c.keepShown(place.Chat, place.ID, words) + c.keepShown(place.Chat, place.ID, said.plain()) } if gone(err) { - place, err = nil, c.newMessage(m.Handle, chat, words, buttons, silent, &id) + place, err = nil, c.newMessage(m.Handle, chat, said, buttons, silent, &id) } default: - err = c.newMessage(m.Handle, chat, words, buttons, silent, &id) + err = c.newMessage(m.Handle, chat, said, buttons, silent, &id) } if err == nil { if earlier != nil { @@ -297,13 +293,15 @@ func (c *Channel) replaced(p Placed) { return } c.Logf("[telegram] an earlier message could not be deleted (%v); its buttons are taken away instead", err) - if err := c.Bot.Edit(p.Chat, p.ID, "Replaced by a newer message below.", nil); err != nil && !gone(err) { + if err := c.Bot.Edit(p.Chat, p.ID, escape("Replaced by a newer message below."), nil); err != nil && !gone(err) { c.Logf("[telegram] an earlier message could not be marked replaced either: %v", err) } } -func (c *Channel) newMessage(handle string, chat int64, words string, buttons [][2]string, silent bool, id *int64) error { - got, err := c.Bot.Send(chat, words, buttons, silent) +// newMessage sends words, rendered (render.go), and keeps where they are and what they say, plain. +func (c *Channel) newMessage(handle string, chat int64, parts shown, buttons [][2]string, silent bool, id *int64) error { + words := parts.plain() + got, err := c.Bot.Send(chat, parts.html(), buttons, silent) if err != nil { return err } @@ -353,7 +351,7 @@ func (c *Channel) Handle(u Update) { // Only on a message showing exactly what this channel put there: one whose words another holder of // the token changed, or one it never showed, answers nothing (the review of 2026-10-09). if want, err := c.Store.Shown(cb.Message.Chat.ID, cb.Message.MessageID); err != nil || want == "" || - want != wordsDigest(cb.Message.Text) { + (want != wordsDigest(cb.Message.Text) && want != linesDigest(cb.Message.Text)) { words := "That answer was not taken: this message does not show what the mesh put there. Nothing was done." c.answerTap(cb.ID, words) // Said on the phone alone, never in the chat: the tapper may be somebody the buttons reached, who is told @@ -461,7 +459,7 @@ func (c *Channel) remember(id string, chat int64) { } func (c *Channel) reply(chat int64, words string) { - if _, err := c.Bot.Send(chat, words, nil, true); err != nil { + if _, err := c.Bot.Send(chat, escape(words), nil, true); err != nil { c.Logf("[telegram] could not answer in the chat: %v", err) } } diff --git a/modules/telegram/cmd/telegram/channel_test.go b/modules/telegram/cmd/telegram/channel_test.go index 863df985..06c96121 100644 --- a/modules/telegram/cmd/telegram/channel_test.go +++ b/modules/telegram/cmd/telegram/channel_test.go @@ -202,7 +202,7 @@ func TestAQuestionIsShownWithItsTicketsAndRingsAndAWarningIsSilent(t *testing.T) } b := sent[0].Body kb, _ := json.Marshal(b["reply_markup"]) - if b["chat_id"] != float64(42) || b["text"] != "Delivery of hq held\nNeeds you: release it." || b["disable_notification"] != false || + if b["chat_id"] != float64(42) || b["text"] != "Delivery of hq held\nNeeds you: release it." || b["disable_notification"] != false || string(kb) != `{"inline_keyboard":[[{"callback_data":"0123456789abcdef0123456789abcdef","text":"Release"}]]}` { t.Errorf("sent %v %s", b, kb) } @@ -230,12 +230,83 @@ func TestALongMessageIsCutAndTheCutSaid(t *testing.T) { if err := c.Take(subject, body); err != nil { t.Fatal(err) } - got := f.of("sendMessage")[0].Body["text"].(string) + // The bound is on the words, as Telegram's is on the text once its markup is read: the tags do not count. + got := strings.NewReplacer("", "", "", "").Replace(f.of("sendMessage")[0].Body["text"].(string)) + got = strings.ReplaceAll(got, ">", ">") if n := len([]rune(got)); n != MaxLength || !strings.HasSuffix(got, cutNote) { t.Errorf("%d characters, ends %q (D1)", n, got[len(got)-40:]) } } +// A message is shown formatted, never reworded (issues 383 and 639): the title bold, a key line in monospace, the +// note apart in a collapsed blockquote, and every '<', '>' and '&' escaped, so a message carrying one neither breaks +// nor injects. The tap on it is still compared by the plain words, which is what Telegram gives back. +func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) { + c, f, s, _ := newChannel(t, goodToken) + subject, body := work(asks.Show, asks.Message{Handle: "q1", Title: "Set mounts on & co?", + Body: "Changes two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)", + Note: "Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc.", + To: "42", Buttons: []asks.Button{{Label: "Approve", Ticket: "tkt"}}}) + if err := c.Take(subject, body); err != nil { + t.Fatal(err) + } + sent := f.of("sendMessage") + want := "Set mounts on <shanks> & co?\nChanges two settings.\n+ shares: media=/storage/media\n" + + "~ sources: a=b (was: c)\n\n
Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc.
" + if len(sent) != 1 || sent[0].Body["text"] != want || sent[0].Body["parse_mode"] != "HTML" { + t.Fatalf("sent %v", sent) + } + f.mu.Lock() + shown := f.next + f.mu.Unlock() + // Telegram gives the words back plain, the space around the blockquote as it pleases: the tap on them is taken. + c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"}, + Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"}, + Text: "Set mounts on & co?\nChanges two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" + + "Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc."}}}) + if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 { + t.Fatalf("the tap on the rendered message was not taken: %s", got) + } + // Words changed by another holder of the token are still told apart. + c.Handle(Update{UpdateID: 2, Callback: &Callback{ID: "cb2", Data: "tkt", From: User{ID: 42, FirstName: "Jo"}, + Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"}, + Text: "Set mounts on & co?\nChanges three settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" + + "Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc."}}}) + if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 { + t.Fatalf("a tap on changed words was taken: %s", got) + } + // The edit says what was chosen, one line with the headline, and is in the same mode. + subject, body = work(asks.Edit, asks.Message{Handle: "q1", Title: "You chose Approve at 15:06 on Telegram · Set mounts on & co?", + Note: "Told to mesh-controller, which acts on it itself.", To: "42"}) + if err := c.Take(subject, body); err != nil { + t.Fatal(err) + } + ed := f.of("editMessageText") + if len(ed) != 1 || ed[0].Body["parse_mode"] != "HTML" || + ed[0].Body["text"] != "You chose Approve at 15:06 on Telegram · Set mounts on <shanks> & co?\n\n
Told to mesh-controller, which acts on it itself.
" { + t.Errorf("edited %v", ed) + } + // A plain answer in the chat is escaped too. + c.reply(42, "a tag & more") + if last := f.of("sendMessage"); last[len(last)-1].Body["text"] != "a <b>tag</b> & more" { + t.Errorf("a reply was not escaped: %v", last[len(last)-1].Body["text"]) + } +} + +// A message shown before issue 639 was kept under the digest of its lines: a tap on it is still taken. +func TestATapOnAMessageShownBeforeIsStillTaken(t *testing.T) { + c, _, s, st := newChannel(t, goodToken) + words := "Delivery held\nRelease or Stop." + if err := st.Show(42, 9, linesDigest(words)); err != nil { + t.Fatal(err) + } + c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"}, + Message: &Msg{MessageID: 9, Chat: Chat{ID: 42, Type: "private"}, Text: words}}}) + if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 { + t.Fatalf("the tap on a message shown before was not taken: %s", got) + } +} + func TestAnEditToTheSameTextIsASuccessAndAGoneMessageIsSentAgain(t *testing.T) { c, f, s, st := newChannel(t, goodToken) st.Place("m1", Placed{Chat: 42, ID: 7}) @@ -528,7 +599,7 @@ func TestATapTheRouterIsSlowOnClaimsNothingAndItsWordComesInTheChat(t *testing.T if err := c.Take(subject, body); err != nil { t.Fatal(err) } - if sent := f.of("sendMessage"); len(sent) != sentBefore+1 || sent[len(sent)-1].Body["text"] != "You chose Release: recorded." { + if sent := f.of("sendMessage"); len(sent) != sentBefore+1 || sent[len(sent)-1].Body["text"] != "You chose Release: recorded." { t.Errorf("the late word was not said in the chat: %v", sent) } // Telegram refuses an acknowledgement that came too late: the word goes to the chat instead. diff --git a/modules/telegram/cmd/telegram/render.go b/modules/telegram/cmd/telegram/render.go new file mode 100644 index 00000000..37f8af5f --- /dev/null +++ b/modules/telegram/cmd/telegram/render.go @@ -0,0 +1,96 @@ +package main + +// How a message looks in the chat (novox/hq issues 383 and 639, the operator's words: "no bold text, no +// formatting", "too much text, no formatting, no summary"). The router's words are shown as given — +// `exact-render` — and formatted, never reworded: +// +// - the title in bold: the headline, or how an answered ask ended; +// - the body as it is, a line that is a key with its value (as a settings proposal lists them, "+ key: value") +// in monospace; +// - the note — who asked, what each answer does, the expiry, the asker's details — apart, after a blank line, +// in a blockquote Telegram shows collapsed to its first lines, expanded on a tap. +// +// Every character Telegram's HTML would read as markup is escaped first, so a message carrying '<' neither +// breaks nor injects. What the operator taps on is compared by its plain words (wordsDigest): Telegram gives a +// message's text back without its formatting, so the digest is over the words before they are rendered. + +import ( + "regexp" + "strings" + "unicode/utf8" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// ParseMode is the formatting the Bot API is told the text is in. +const ParseMode = "HTML" + +var keyLine = regexp.MustCompile(`^[+~=-] `) + +// escape is a text with the characters Telegram's HTML reads as markup written as entities. +func escape(s string) string { + return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(s) +} + +// shown is a message's three parts as the chat carries them. +type shown struct{ title, body, note string } + +// partsOf is a message's parts, cut so that its plain words fit MaxLength and the cut said (D1): the note is cut +// first, then the body. The bound is on the words, as Telegram's is on the text once its markup is read. +func partsOf(m asks.Message) shown { + s := shown{title: strings.TrimSpace(m.Title), body: strings.TrimSpace(m.Body), note: strings.TrimSpace(m.Note)} + over := utf8.RuneCountInString(s.plain()) - MaxLength + if over <= 0 { + return s + } + mark := utf8.RuneCountInString(cutNote) + cut := func(text string, by int) string { + r := []rune(text) + keep := max(len(r)-by-mark, 0) + return string(r[:keep]) + cutNote + } + if utf8.RuneCountInString(s.note) > over+mark { + s.note = cut(s.note, over) + return s + } + s.note = "" + keep := max(MaxLength-utf8.RuneCountInString(s.title)-1-mark, 0) + if r := []rune(s.body); keep < len(r) { + s.body = string(r[:keep]) + } + s.body += cutNote + return s +} + +// plain is the words as Telegram gives them back: the title, the body on the lines after it, the note after a +// blank line. +func (s shown) plain() string { + t := s.title + if s.body != "" { + t += "\n" + s.body + } + if s.note != "" { + t += "\n\n" + s.note + } + return t +} + +// html is the same words as the chat shows them. +func (s shown) html() string { + var b strings.Builder + b.WriteString("" + escape(s.title) + "") + if s.body != "" { + for _, line := range strings.Split(s.body, "\n") { + b.WriteString("\n") + if keyLine.MatchString(line) { + b.WriteString("" + escape(line) + "") + } else { + b.WriteString(escape(line)) + } + } + } + if s.note != "" { + b.WriteString("\n\n
" + escape(s.note) + "
") + } + return b.String() +} diff --git a/modules/telegram/cmd/telegram/render_test.go b/modules/telegram/cmd/telegram/render_test.go new file mode 100644 index 00000000..5ae2f9eb --- /dev/null +++ b/modules/telegram/cmd/telegram/render_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// adr0343 is the ask for ADR 0343 as the router now sends it to this channel (novox/hq issue 639): the knowledge +// base's brief as its title and body, the rest in its note. On 2026-10-13 the same ask arrived as about thirty plain +// lines — every one of its thirteen clauses on a line of its own — and the operator would not read it. +var adr0343 = asks.Message{Handle: "h-343", To: "42", + Title: "Your agents' memory follows you to every machine", + Body: "What an agent learns in a project's memory is copied to every machine that runs the agent, so a session " + + "anywhere starts with it. A file changed on two machines keeps both versions and warns you; secrets in memory files " + + "are screened, though not perfectly.\n" + + "• Only memory folders travel: links and files over 128 KiB stay where they are\n" + + "• A conflict reaches you as a warning that needs you\n" + + "• A deleted file is marked, and a newer change elsewhere outlives the deletion", + Note: "Asked by mesh-knowledge at 16:05. Unanswered by tomorrow 15:55: the record stays proposed; nothing of it is in force.\n" + + "Approve: the record is accepted and its clauses are in force. Decline: the record stays proposed; nothing of it is in force.\n" + + "ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.\n" + + "Read its 13 clauses with the knowledge base's get, number 343.", + Buttons: []asks.Button{{Label: "Approve", Ticket: "t-approve"}, {Label: "Decline", Ticket: "t-decline"}}} + +// The ADR 0343 ask fits a phone's glance: a bold headline, the summary and its points, then the rest apart and +// collapsed — at most twelve lines in all, and never a clause. +func TestTheADR0343AskFitsAGlance(t *testing.T) { + c, f, _, _ := newChannel(t, goodToken) + subject, body := work(asks.Show, adr0343) + if err := c.Take(subject, body); err != nil { + t.Fatal(err) + } + sent := f.of("sendMessage") + if len(sent) != 1 { + t.Fatalf("sent %d", len(sent)) + } + html := sent[0].Body["text"].(string) + t.Logf("as sent:\n%s", html) + lines := strings.Split(html, "\n") + if len(lines) > 12 { + t.Errorf("the ask is %d lines, more than 12:\n%s", len(lines), html) + } + if lines[0] != "Your agents' memory follows you to every machine" && lines[0] != "Your agents' memory follows you to every machine" { + t.Errorf("the headline is not first and bold: %q", lines[0]) + } + if !strings.HasPrefix(lines[1], "What an agent learns") || strings.Count(html, "\n• ") != 3 { + t.Errorf("the summary and its three points do not follow the headline:\n%s", html) + } + if !strings.Contains(html, "\n\n
Asked by mesh-knowledge at 16:05.") || !strings.HasSuffix(html, "number 343.
") { + t.Errorf("the note is not apart and collapsed:\n%s", html) + } + for _, lead := range []string{"Only the memory folders are carried", "How it is checked", "13 clause(s)", "**"} { + if strings.Contains(html, lead) { + t.Errorf("the ask carries %q", lead) + } + } + if sent[0].Body["parse_mode"] != "HTML" { + t.Errorf("not sent as HTML: %v", sent[0].Body["parse_mode"]) + } +} + +// A long note is cut before the body is, and the cut said; a body too long loses the note and is cut itself. +func TestALongNoteIsCutBeforeTheBody(t *testing.T) { + m := asks.Message{Title: "t", Body: "the summary", Note: strings.Repeat("n", 5000)} + s := partsOf(m) + if s.body != "the summary" || !strings.HasSuffix(s.note, cutNote) || len([]rune(s.plain())) != MaxLength { + t.Errorf("body %q, note ends %q, %d characters", s.body, s.note[len(s.note)-20:], len([]rune(s.plain()))) + } + m.Body = strings.Repeat("b", 5000) + s = partsOf(m) + if s.note != "" || !strings.HasSuffix(s.body, cutNote) || len([]rune(s.plain())) != MaxLength { + t.Errorf("note %d, %d characters", len(s.note), len([]rune(s.plain()))) + } +} diff --git a/modules/telegram/go.mod b/modules/telegram/go.mod index 7b6aa768..ab5e7c21 100644 --- a/modules/telegram/go.mod +++ b/modules/telegram/go.mod @@ -2,4 +2,4 @@ module telegram go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.10 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 diff --git a/modules/telegram/go.sum b/modules/telegram/go.sum index 8446865c..b5bb9911 100644 --- a/modules/telegram/go.sum +++ b/modules/telegram/go.sum @@ -1,2 +1,4 @@ git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o= git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= From bb5a342863ee2b25373e9bb96722a6921bb92b7b Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 17:02:20 +0200 Subject: [PATCH 2/3] Every value an approval sets is in the note, whole where the sender is proven; the note is kept whole (issue 383) The review of mesh-controller #304: a proposal of more than three keys showed only the names of the rest, and its Details could carry no value, so Approve bound values the operator never saw. The router now shows an ask's WholeDetails in the note in place of its Details on a kind that proves who answers, held to the secret rule alone, and refuses an ask whose whole details carry a secret's shape. Telegram keeps the note whole when a message is too long, cutting the body first and saying so, and escapes the note as every other part. SDK at mesh-sdk f474544. --- modules/claude-code/go.mod | 2 +- modules/claude-code/go.sum | 2 + modules/desk-channel/go.mod | 2 +- modules/desk-channel/go.sum | 2 + modules/mesh-knowledge/go.mod | 2 +- modules/mesh-knowledge/go.sum | 2 + modules/messenger/README.md | 4 +- modules/messenger/cmd/messenger/asks.go | 35 +++++++++--- modules/messenger/cmd/messenger/holder.go | 10 +++- modules/messenger/cmd/messenger/whole_test.go | 55 +++++++++++++++++++ modules/messenger/go.mod | 2 +- modules/messenger/go.sum | 2 + modules/telegram/README.md | 2 +- modules/telegram/cmd/telegram/render.go | 25 ++++----- modules/telegram/cmd/telegram/render_test.go | 26 ++++++--- modules/telegram/go.mod | 2 +- modules/telegram/go.sum | 2 + 17 files changed, 137 insertions(+), 40 deletions(-) diff --git a/modules/claude-code/go.mod b/modules/claude-code/go.mod index 6562a740..3ae9efa8 100644 --- a/modules/claude-code/go.mod +++ b/modules/claude-code/go.mod @@ -2,6 +2,6 @@ module claude-code go 1.25.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d require gopkg.in/yaml.v3 v3.0.1 diff --git a/modules/claude-code/go.sum b/modules/claude-code/go.sum index f09ac821..e31a5b11 100644 --- a/modules/claude-code/go.sum +++ b/modules/claude-code/go.sum @@ -2,6 +2,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.13 h1:Su4JYpZ+zhNovkGA2DgxiZdcuHpjw2tPJzc/7P git.novox.be/novox/mesh-sdk/go v0.1.13/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/modules/desk-channel/go.mod b/modules/desk-channel/go.mod index c3cc8428..fc97ce5b 100644 --- a/modules/desk-channel/go.mod +++ b/modules/desk-channel/go.mod @@ -2,4 +2,4 @@ module deskchannel go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d diff --git a/modules/desk-channel/go.sum b/modules/desk-channel/go.sum index b5bb9911..0df2f17b 100644 --- a/modules/desk-channel/go.sum +++ b/modules/desk-channel/go.sum @@ -2,3 +2,5 @@ git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSm git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/mesh-knowledge/go.mod b/modules/mesh-knowledge/go.mod index 5e41d704..0d489090 100644 --- a/modules/mesh-knowledge/go.mod +++ b/modules/mesh-knowledge/go.mod @@ -3,7 +3,7 @@ module mesh-knowledge go 1.25.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d github.com/jackc/pgx/v5 v5.11.0 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.51.0 diff --git a/modules/mesh-knowledge/go.sum b/modules/mesh-knowledge/go.sum index 06f07d11..fdc7fe21 100644 --- a/modules/mesh-knowledge/go.sum +++ b/modules/mesh-knowledge/go.sum @@ -2,6 +2,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011091942-4cf45b5ca0cf h1:ED/+RszK git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011091942-4cf45b5ca0cf/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/modules/messenger/README.md b/modules/messenger/README.md index 0ead976d..7a70a347 100644 --- a/modules/messenger/README.md +++ b/modules/messenger/README.md @@ -136,7 +136,9 @@ condition's message. A warning kept quiet is also said once after a day. explanation alone — a summary, then at most three points (`asks.Glance`; an ask that does not hold to it is shown and said in the log). Everything else is the message's note (`asks.Message.Note`), which a channel shows apart and smaller: who asked and when, with what happens if nobody answers; what each answer does; where else it is answered; -and the asker's Details (who proposed it, a fingerprint, how to read it whole). Answered, the message is one line — +and the asker's Details (who proposed it, a fingerprint, how to read it whole) — on a kind that proves who answers +and was given the ask's answers, its Details whole (`asks.Ask.WholeDetails`): every value an approval sets, held to +the secret rule alone, as the whole words are. Answered, the message is one line — "You chose Approve at 16:06 on Telegram · " — and the router's word on the asker is its note. **An ask's values are shown whole where the sender is proven** (hq issue 383). An ask may carry its explanation diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index 04ac5d04..c98c7ace 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -172,22 +172,39 @@ func (h *Holder) openAsk(r *Record) *AskRecord { // rule, is shown. The ask's state does not matter: a message edited to say how the ask ended still shows what // was approved. func (h *Holder) wholeOf(r *Record, kind string) string { + if a := h.provenAsk(r, kind); a != nil { + return a.Whole + } + return "" +} + +// wholeDetailsOf is an ask's Details with their values whole (asks.Ask.WholeDetails), shown in the note in place of +// its Details where wholeOf would show its whole words: every value an approval sets, so the operator reads exactly +// what they approve (the review of mesh-controller #304). Empty elsewhere. +func (h *Holder) wholeDetailsOf(r *Record, kind string) string { + if a := h.provenAsk(r, kind); a != nil { + return a.WholeDetails + } + return "" +} + +// provenAsk is the ask of an ask's own record when the kind proves who answers and was given the ask's answers; +// nil otherwise. +func (h *Holder) provenAsk(r *Record, kind string) *asks.Ask { if r == nil || r.Origin != "ask" || r.Ask == "" || kind == "" || h.Bench == nil { - return "" + return nil } e := h.askOf(r.Ask) - if e == nil || e.rec.Ask.Whole == "" { - return "" - } - if !h.Bench.Promises(kind, "verified-sender") { - return "" + if e == nil || !h.Bench.Promises(kind, "verified-sender") { + return nil } for _, tk := range e.rec.Tickets { if tk.Kind == kind { - return e.rec.Ask.Whole + a := e.rec.Ask + return &a } } - return "" + return nil } // carriers are the kinds that can carry an answer of a level now: ready, offering a choice, and for @@ -474,7 +491,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error { // The whole words are shown only where the sender is proven (wholeOf), so a path or an address may stand // in them; a secret's shape leaves the mesh nowhere, and an ask carrying one is refused as a whole, never // shown with its explanation alone (novox/hq issue 383). - if refusal, ok := CheckSecret(a.Whole); !ok { + if refusal, ok := CheckSecret(a.Whole + "\n" + a.WholeDetails); !ok { h.refuseAsk(asker, a, "its whole words carry "+refusal.String()+", which may not leave the mesh on any channel: withhold it") return nil } diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 8b3f448a..29795a39 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -885,11 +885,17 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message { note = append(note, line) } } - for _, line := range strings.Split(strings.TrimSpace(rec.Ask.Details), "\n") { + // On a kind that proves who answers, the Details whole (issue 383): every value an approval sets, held to + // the secret rule alone, as the whole words are. + details, passes := rec.Ask.Details, Check + if wd := h.wholeDetailsOf(r, kind); wd != "" { + details, passes = wd, CheckSecret + } + for _, line := range strings.Split(strings.TrimSpace(details), "\n") { if line = strings.TrimSpace(line); line == "" { continue } - if refusal, ok := Check(line); !ok { + if refusal, ok := passes(line); !ok { line = "(a line carrying " + refusal.What + " is withheld here)" } note = append(note, line) diff --git a/modules/messenger/cmd/messenger/whole_test.go b/modules/messenger/cmd/messenger/whole_test.go index a094cfe2..092b7b9b 100644 --- a/modules/messenger/cmd/messenger/whole_test.go +++ b/modules/messenger/cmd/messenger/whole_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "strings" "testing" "time" @@ -153,3 +154,57 @@ func TestAnAskWhoseWholeWordsCarryASecretIsRefused(t *testing.T) { } } } + +// The review of mesh-controller #304: a proposal of five keys, one of them 300 characters long, shows two keys at +// a glance; every key and every value, whole, is in the note on a kind that proves who answers, so the operator can +// read exactly what Approve sets. The desk, which proves nothing, shows them masked. +func TestEveryKeyAndItsWholeValueIsInTheNoteWhereTheSenderIsProven(t *testing.T) { + h, tg, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + var long strings.Builder + long.WriteString("library=/storage") + for i := 0; long.Len() < 300; i++ { + long.WriteString(fmt.Sprintf("/album-%02d", i)) + } + value := long.String()[:300] + keys := map[string]string{"a": "1", "b": "2", "c": "3", "d": "4", "shares": value} + masked := map[string]string{"a": "1", "b": "2", "c": "3", "d": "4", "shares": "library=‹path›"} + lines := func(values map[string]string) string { + var out []string + for _, k := range []string{"a", "b", "c", "d", "shares"} { + out = append(out, "+ "+k+": "+values[k]) + } + return strings.Join(out, "\n") + } + a := proposalAsk(c, "s-9") + a.Explanation = "Changes 5 setting(s) of mounts on shanks.\n+ a: 1\n+ b: 2\nand 3 more, each whole below: c, d, shares" + a.Whole = "" + a.Details = "Fingerprint 1234 5678 9abc def0 1234 5678.\n" + lines(masked) + a.WholeDetails = "Fingerprint 1234 5678 9abc def0 1234 5678.\n" + lines(keys) + submit(t, h, "mesh-controller", a) + settle(h, c) + if len(tg.sends) != 1 { + t.Fatalf("telegram got %d messages", len(tg.sends)) + } + phone := tg.sends[0] + for k, v := range keys { + if !strings.Contains(phone.Note, "+ "+k+": "+v) { + t.Errorf("the phone's note lacks %s whole:\n%s", k, phone.Note) + } + } + if strings.Contains(phone.Body, "/storage") || strings.Count(phone.Body, "\n") > 3 { + t.Errorf("the body is not the glance:\n%s", phone.Body) + } + desk := h.sayOn(h.open["ask.mesh-controller.s-9"], "raised", "desktop") + if strings.Contains(desk.Text(), "/storage") || !strings.Contains(desk.Note, "+ shares: library=‹path›") { + t.Errorf("the desk shows a value whole, or not every key:\n%s", desk.Note) + } + // A secret's shape in the whole details refuses the ask as a whole. + b := proposalAsk(c, "s-10") + b.WholeDetails = "+ token: ghp_abcdefghijklmnopqrstuvwxyz0123456789" + submit(t, h, "mesh-controller", b) + settle(h, c) + if _, kept := store.asks["mesh-controller.s-10"]; kept || len(tg.sends) != 1 { + t.Errorf("an ask whose whole details carry a secret was taken") + } +} diff --git a/modules/messenger/go.mod b/modules/messenger/go.mod index d471d086..d03c1405 100644 --- a/modules/messenger/go.mod +++ b/modules/messenger/go.mod @@ -2,4 +2,4 @@ module messenger go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d diff --git a/modules/messenger/go.sum b/modules/messenger/go.sum index 75e71c63..f3f5b3ae 100644 --- a/modules/messenger/go.sum +++ b/modules/messenger/go.sum @@ -2,3 +2,5 @@ git.novox.be/novox/mesh-sdk/go v0.1.13 h1:Su4JYpZ+zhNovkGA2DgxiZdcuHpjw2tPJzc/7P git.novox.be/novox/mesh-sdk/go v0.1.13/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/telegram/README.md b/modules/telegram/README.md index c12fe8eb..f8821915 100644 --- a/modules/telegram/README.md +++ b/modules/telegram/README.md @@ -31,7 +31,7 @@ as the operator. | | fixed here | |---|---| | D1 | up to 4096 characters arrive whole; longer is cut and the cut said | -| issues 383, 639 | shown formatted (HTML), never reworded: the title bold, the body as it is with a key line in monospace, the note apart in a blockquote collapsed to its first lines; `<`, `>` and `&` escaped first, so nothing in the words is read as markup. A tap is compared by the plain words, whitespace aside | +| issues 383, 639 | shown formatted (HTML), never reworded: the title bold, the body as it is with a key line in monospace, the note apart in a blockquote collapsed to its first lines, kept whole when a message is too long (the body is cut first, and the cut said); `<`, `>` and `&` escaped first, so nothing in the words is read as markup. A tap is compared by the plain words, whitespace aside | | D4 | a warning or a clearing arrives without sound; an urgent message or a question rings | | D5 | a rate limit is waited out as told | | D6 | a refusal is a permanent failure, said to the router and not retried; an edit to the same text is a success | diff --git a/modules/telegram/cmd/telegram/render.go b/modules/telegram/cmd/telegram/render.go index 37f8af5f..9bdd257f 100644 --- a/modules/telegram/cmd/telegram/render.go +++ b/modules/telegram/cmd/telegram/render.go @@ -35,8 +35,10 @@ func escape(s string) string { // shown is a message's three parts as the chat carries them. type shown struct{ title, body, note string } -// partsOf is a message's parts, cut so that its plain words fit MaxLength and the cut said (D1): the note is cut -// first, then the body. The bound is on the words, as Telegram's is on the text once its markup is read. +// partsOf is a message's parts, cut so that its plain words fit MaxLength and the cut said (D1). The body is cut +// first: the note carries what each answer does and every value an approval sets, so it is kept whole as long as +// the title and the note fit; only then is the note cut, and that said too. The bound is on the words, as +// Telegram's is on the text once its markup is read. func partsOf(m asks.Message) shown { s := shown{title: strings.TrimSpace(m.Title), body: strings.TrimSpace(m.Body), note: strings.TrimSpace(m.Note)} over := utf8.RuneCountInString(s.plain()) - MaxLength @@ -44,21 +46,16 @@ func partsOf(m asks.Message) shown { return s } mark := utf8.RuneCountInString(cutNote) - cut := func(text string, by int) string { - r := []rune(text) - keep := max(len(r)-by-mark, 0) - return string(r[:keep]) + cutNote - } - if utf8.RuneCountInString(s.note) > over+mark { - s.note = cut(s.note, over) + if r := []rune(s.body); len(r) >= over+mark { + s.body = string(r[:len(r)-over-mark]) + cutNote return s } - s.note = "" - keep := max(MaxLength-utf8.RuneCountInString(s.title)-1-mark, 0) - if r := []rune(s.body); keep < len(r) { - s.body = string(r[:keep]) + // The body cannot make room alone: what is left of it is the cut's word, and the note is cut too. + s.body = strings.TrimPrefix(cutNote, "\n") + if over = utf8.RuneCountInString(s.plain()) - MaxLength; over > 0 { + r := []rune(s.note) + s.note = string(r[:max(len(r)-over-mark, 0)]) + cutNote } - s.body += cutNote return s } diff --git a/modules/telegram/cmd/telegram/render_test.go b/modules/telegram/cmd/telegram/render_test.go index 5ae2f9eb..a2658313 100644 --- a/modules/telegram/cmd/telegram/render_test.go +++ b/modules/telegram/cmd/telegram/render_test.go @@ -61,16 +61,26 @@ func TestTheADR0343AskFitsAGlance(t *testing.T) { } } -// A long note is cut before the body is, and the cut said; a body too long loses the note and is cut itself. -func TestALongNoteIsCutBeforeTheBody(t *testing.T) { - m := asks.Message{Title: "t", Body: "the summary", Note: strings.Repeat("n", 5000)} +// The note is kept whole when the message is too long: the body is cut first and the cut said; only a note that +// cannot fit beside the title is cut, and that said too (the review of mesh-catalog #278). +func TestALongBodyIsCutBeforeTheNote(t *testing.T) { + m := asks.Message{Title: "t", Body: strings.Repeat("b", 5000), Note: "+ shares: media=/storage/media\nFingerprint 1234."} s := partsOf(m) - if s.body != "the summary" || !strings.HasSuffix(s.note, cutNote) || len([]rune(s.plain())) != MaxLength { + if s.note != m.Note || !strings.HasSuffix(s.body, cutNote) || len([]rune(s.plain())) != MaxLength { + t.Errorf("note %q, body ends %q, %d characters", s.note, s.body[len(s.body)-20:], len([]rune(s.plain()))) + } + m.Body, m.Note = "the summary", strings.Repeat("n", 5000) + s = partsOf(m) + if !strings.HasSuffix(s.note, cutNote) || !strings.Contains(s.body, "cut here") || len([]rune(s.plain())) != MaxLength { t.Errorf("body %q, note ends %q, %d characters", s.body, s.note[len(s.note)-20:], len([]rune(s.plain()))) } - m.Body = strings.Repeat("b", 5000) - s = partsOf(m) - if s.note != "" || !strings.HasSuffix(s.body, cutNote) || len([]rune(s.plain())) != MaxLength { - t.Errorf("note %d, %d characters", len(s.note), len([]rune(s.plain()))) +} + +// A '<', '>' or '&' in the note is escaped like any other part: it neither breaks the blockquote nor injects. +func TestTheNoteIsEscaped(t *testing.T) { + got := partsOf(asks.Message{Title: "t", Note: "Asked by x & y "}).html() + want := "t\n\n
Asked by <b>x</b> & y </blockquote><i>
" + if got != want { + t.Errorf("%q", got) } } diff --git a/modules/telegram/go.mod b/modules/telegram/go.mod index ab5e7c21..f27b7fe6 100644 --- a/modules/telegram/go.mod +++ b/modules/telegram/go.mod @@ -2,4 +2,4 @@ module telegram go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d diff --git a/modules/telegram/go.sum b/modules/telegram/go.sum index b5bb9911..0df2f17b 100644 --- a/modules/telegram/go.sum +++ b/modules/telegram/go.sum @@ -2,3 +2,5 @@ git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSm git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= From f09daabec3a8791b4d2ba44c4d5102ed40a3f570 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 20:32:50 +0200 Subject: [PATCH 3/3] A withheld change line in the note keeps its key's name; SDK at 5933eb7 (the review of mesh-controller #304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router's fallback said a refused note line as "(a line carrying … is withheld here)", so the operator could not tell which setting held a value they were not shown. A change's line now keeps its key; any other line, or one whose key is refused too, is said withheld whole. The SDK pin moves to mesh-sdk 5933eb7, where no two asks digest the same. --- modules/claude-code/go.mod | 2 +- modules/claude-code/go.sum | 2 ++ modules/desk-channel/go.mod | 2 +- modules/desk-channel/go.sum | 2 ++ modules/mesh-knowledge/go.mod | 2 +- modules/mesh-knowledge/go.sum | 2 ++ modules/messenger/cmd/messenger/holder.go | 19 +++++++++++++++++- modules/messenger/cmd/messenger/whole_test.go | 20 +++++++++++++++++++ modules/messenger/go.mod | 2 +- modules/messenger/go.sum | 2 ++ modules/telegram/go.mod | 2 +- modules/telegram/go.sum | 2 ++ 12 files changed, 53 insertions(+), 6 deletions(-) diff --git a/modules/claude-code/go.mod b/modules/claude-code/go.mod index 3ae9efa8..cc52e29f 100644 --- a/modules/claude-code/go.mod +++ b/modules/claude-code/go.mod @@ -2,6 +2,6 @@ module claude-code go 1.25.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 require gopkg.in/yaml.v3 v3.0.1 diff --git a/modules/claude-code/go.sum b/modules/claude-code/go.sum index e31a5b11..c26c88fc 100644 --- a/modules/claude-code/go.sum +++ b/modules/claude-code/go.sum @@ -4,6 +4,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/modules/desk-channel/go.mod b/modules/desk-channel/go.mod index fc97ce5b..71de4c12 100644 --- a/modules/desk-channel/go.mod +++ b/modules/desk-channel/go.mod @@ -2,4 +2,4 @@ module deskchannel go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 diff --git a/modules/desk-channel/go.sum b/modules/desk-channel/go.sum index 0df2f17b..0371ccdf 100644 --- a/modules/desk-channel/go.sum +++ b/modules/desk-channel/go.sum @@ -4,3 +4,5 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/mesh-knowledge/go.mod b/modules/mesh-knowledge/go.mod index 0d489090..9666cf4c 100644 --- a/modules/mesh-knowledge/go.mod +++ b/modules/mesh-knowledge/go.mod @@ -3,7 +3,7 @@ module mesh-knowledge go 1.25.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 github.com/jackc/pgx/v5 v5.11.0 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.51.0 diff --git a/modules/mesh-knowledge/go.sum b/modules/mesh-knowledge/go.sum index fdc7fe21..600f78a9 100644 --- a/modules/mesh-knowledge/go.sum +++ b/modules/mesh-knowledge/go.sum @@ -4,6 +4,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 29795a39..67310e9a 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -896,7 +896,7 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message { continue } if refusal, ok := passes(line); !ok { - line = "(a line carrying " + refusal.What + " is withheld here)" + line = withheldLine(line, refusal, passes) } note = append(note, line) } @@ -1146,3 +1146,20 @@ func roughly(d time.Duration) string { } return fmt.Sprintf("%d days", int(d.Hours()/24)) } + +// withheldLine is a note line the rule refused, said withheld: a change's line ("+ key: value") keeps its key's name, +// so the operator knows which setting holds a value they are not shown (the review of mesh-controller #304); any +// other line, or one whose key the rule refuses too, is said withheld whole. +func withheldLine(line string, refusal Refusal, passes func(string) (Refusal, bool)) string { + if len(line) > 2 && strings.ContainsRune("+~-=", rune(line[0])) && line[1] == ' ' { + key := line[2:] + if i := strings.IndexAny(key, ": "); i > 0 { + key = key[:i] + } + named := line[:2] + key + " (a value carrying " + refusal.What + " is withheld here)" + if _, ok := passes(named); ok { + return named + } + } + return "(a line carrying " + refusal.What + " is withheld here)" +} diff --git a/modules/messenger/cmd/messenger/whole_test.go b/modules/messenger/cmd/messenger/whole_test.go index 092b7b9b..93b3e4d3 100644 --- a/modules/messenger/cmd/messenger/whole_test.go +++ b/modules/messenger/cmd/messenger/whole_test.go @@ -208,3 +208,23 @@ func TestEveryKeyAndItsWholeValueIsInTheNoteWhereTheSenderIsProven(t *testing.T) t.Errorf("an ask whose whole details carry a secret was taken") } } + +// A change's line the rule refuses keeps its key's name in the note (the review of mesh-controller #304): the +// operator knows which setting holds a value they are not shown. A line whose key the rule refuses too is said +// withheld whole. +func TestAWithheldChangeLineKeepsItsKey(t *testing.T) { + for line, want := range map[string]string{ + "+ password: hunter2": "+ password (a value carrying a value given to a secret's name is withheld here)", + "~ shares: /mnt/media (was: x)": "~ shares (a value carrying a file path is withheld here)", + "Read it at /etc/x": "(a line carrying a file path is withheld here)", + "+ ghp_abcdefghijklmnopqrstuvwxyz0123456789: 1": "(a line carrying ", + } { + refusal, ok := Check(line) + if ok { + t.Fatalf("%q passes the content rule", line) + } + if got := withheldLine(line, refusal, Check); !strings.HasPrefix(got, want) { + t.Errorf("%q: %q, want %q", line, got, want) + } + } +} diff --git a/modules/messenger/go.mod b/modules/messenger/go.mod index d03c1405..2f961edb 100644 --- a/modules/messenger/go.mod +++ b/modules/messenger/go.mod @@ -2,4 +2,4 @@ module messenger go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 diff --git a/modules/messenger/go.sum b/modules/messenger/go.sum index f3f5b3ae..3d45e21c 100644 --- a/modules/messenger/go.sum +++ b/modules/messenger/go.sum @@ -4,3 +4,5 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/telegram/go.mod b/modules/telegram/go.mod index f27b7fe6..6d9c8073 100644 --- a/modules/telegram/go.mod +++ b/modules/telegram/go.mod @@ -2,4 +2,4 @@ module telegram go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 diff --git a/modules/telegram/go.sum b/modules/telegram/go.sum index 0df2f17b..0371ccdf 100644 --- a/modules/telegram/go.sum +++ b/modules/telegram/go.sum @@ -4,3 +4,5 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=