Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
3f7aafe
feat(mcpl): stamp each accepted inbound item with its source envelope
Oct 7, 2026
eb2a588
fix(mcpl): freeze ordinary inbound envelopes at admission; add lane f…
Oct 7, 2026
7c3d230
Merge Basil-1007's RecordJournal (2153e3d) as the shared base for the…
Oct 7, 2026
7144970
feat(mcpl): type MCPL request failures by what reached the server
Oct 7, 2026
0a8e9c7
Merge typed MCPL request failures (7144970)
Oct 7, 2026
2c591a7
feat(mcpl): one publish executor with structured delivery outcomes
Oct 7, 2026
7a91066
feat(drafts): hold suppressed plain speech as resendable drafts
Oct 7, 2026
15ec398
fix(mcpl): refuse invalid publish selectors; a contradictory receipt …
Oct 7, 2026
8e4146d
fix(drafts): keep uncertainty across attempts; resends own and re-che…
Oct 7, 2026
191ab09
fix(drafts): a re-bounce keeps the whole text and its risk; notices d…
Oct 7, 2026
6c5f054
fix(drafts): owner-scoped notice identity; true risk evidence; idempo…
Oct 7, 2026
e9099e5
fix(drafts): held envelopes keep hybrid state, receipts read draft st…
Oct 7, 2026
467a48f
fix(drafts): a held-draft notice gives a copied draft its risk; "noth…
Oct 7, 2026
a14e25a
fix(drafts): "not sent" only over words not at risk; exact-text claim…
Oct 7, 2026
1d6d2bb
Merge main (02adf27) into feat/prose-drafts: bring #247 onto current …
Oct 8, 2026
315d12c
fix(drafts): refuse a dismissal while its draft's attempt is out; the…
Oct 8, 2026
378c2b3
test(drafts): pin that a draft's sending mark ends with its own attem…
Oct 10, 2026
2c198b5
fix(drafts): notices count on the branch they're on, the catch-up nam…
Oct 11, 2026
548071d
fix(publish): a publish always has a deadline, a refused send opens n…
Oct 11, 2026
d80210e
fix(drafts): a refused {{unsent}} holds its envelope, dismissals name…
Oct 11, 2026
9feee54
fix(envelope): only the framework writes the envelope, conversation k…
Oct 11, 2026
7cc9c21
test(publish): the refused-before-open case uses a grant that could o…
Oct 11, 2026
5082128
fix(chronicle): an append that throws inside the store blocks the jou…
Oct 12, 2026
38df4fd
docs(drafts): say what a journal that refuses writes means for the dr…
Oct 12, 2026
01dc520
Merge main into feat/prose-drafts
Oct 13, 2026
3ef9f6b
test(drafts): drive the explicit gateway through its admitting entry
Oct 13, 2026
b5f0adb
fix(prose): retain failed envelopes and settle queued delivery outcomes
Oct 13, 2026
ed626b8
test(prose): keep uncertain failures visible when connector data cann…
Oct 13, 2026
4bb6939
test(mcpl): reject late handling for requests whose write failed
Oct 13, 2026
e76f2c9
test(mcpl): arm the observer before asserting no late render response
Oct 13, 2026
b2a21ec
fix(drafts): short notices with no draft words, one notice per failed…
Oct 13, 2026
264704e
test(mcpl): bound the channels/open deadline test by its own timeout
Oct 13, 2026
2836849
docs(drafts): say what the notices and changelog claim exactly; a tes…
Oct 13, 2026
2e316bd
test(drafts): the held-draft and bounce notices quote none of the wor…
Oct 13, 2026
77cd066
fix(drafts): a failed {{unsent}} send re-bounces the draft it substit…
Oct 13, 2026
9169bc9
fix(drafts): a bounce notice offers {{unsent}} only when {{unsent}} w…
Oct 13, 2026
0f4d8a8
fix(drafts): a thrown send's bounce notice says it may not have been …
Oct 13, 2026
1479d48
test(drafts): the overtaken-send test reloads the store while the sen…
Oct 13, 2026
082d901
fix(drafts): a thrown send with no draft behind it is marker-only, as…
Oct 13, 2026
c8fc831
test(drafts): the hybrid throw test follows the marker-only rule
Oct 13, 2026
7dec0cc
Merge main (c2f9baee: #263, #312) into feat/prose-drafts
Oct 13, 2026
07d6223
fix(drafts): a logical turn's drafts settle at its end, and the catch…
Oct 13, 2026
2f50a4b
test(drafts): a hold beside a send writes its own round's routing; an…
Oct 13, 2026
78ce9aa
fix(drafts): a round carries its turn token; the catch-up says what i…
Oct 13, 2026
3a458ee
fix(drafts): notices that name a draft say so in their metadata; the …
Oct 13, 2026
1b9c572
fix(drafts): the drafts-held trace event takes its reason type from D…
Oct 13, 2026
355ade7
fix(drafts): the receipt reports a draft retention let go during the …
Oct 13, 2026
89fda1d
fix(drafts): a receipt with a let-go draft says "nothing confirmed", …
Oct 13, 2026
f03b9ff
refactor(mcpl): drop McplRequestError's code, which nothing reads
Oct 17, 2026
8e2814a
Merge main (24826254) into petra/af247-fixes
Oct 17, 2026
4a69a4f
refactor(drafts): drop each draft's source, which nothing reads
Oct 17, 2026
3099542
fix(publish): channel_publish answers by the receipt rule, and an unc…
Oct 17, 2026
05a9f14
fix(drafts): every notice about a possibly-posted draft says a resend…
Oct 17, 2026
0ce5c34
fix(drafts, publish): connector detail wherever an unconfirmed send i…
Oct 17, 2026
7f24bdc
fix(drafts): seven-character draft ids, and the texts say a let-go id…
Oct 17, 2026
5b73b03
fix(drafts): the drafts tool's example id has seven characters
Oct 17, 2026
c1d60be
fix(drafts, inbound): a puppeted drafts call reaches the drafts tool,…
Oct 17, 2026
ce57229
fix(drafts): a failed attempt says what the draft reads as, a dismiss…
Oct 17, 2026
df89fad
docs(changelog): a connector that answers channels/publish with no re…
Oct 17, 2026
9e0ace8
Merge main (3af13bf4) into petra/af247-fixes
Oct 17, 2026
aa38879
Test the refused wake's saved silence: 'private' over 'send'
Oct 17, 2026
b9b7fa0
Merge main (86a36834) into petra/af247-fixes
Oct 17, 2026
6fb28b7
Merge main (07563022) into petra/af247-fixes
Oct 18, 2026
cc8fd35
Merge main (c5d857b1) into petra/af247-fixes
Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions changelog.d/inbound-source-envelope.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
- **Inbound source envelope.** Every inbound item the host accepts now carries
`metadata.inboundSource`, stamped once where it was accepted: the
conversation it belongs to (`channel`: MCPL server, endpoint binding,
canonical channel id, thread, message, reply edge and the channel's label at
receipt; `unscoped` for a push that names no channel; `surface` for console
or API input from a module), the host acceptance time and the adapter's own
timestamp. It covers `channels/incoming` and `push/event` on both the
ordinary and the RFC-006 coalesced paths; a coalesced occurrence freezes its
envelope at acceptance, so deferral, fan-out and replay deliver it unchanged
and a later rename never rewrites it. Adapter-supplied metadata cannot set
the field, and one a module's messages carry is dropped (an adapter's own
`origin.source` string is untouched). `readInboundSource`,
`conversationKey` (a JSON-encoded key, so no server-chosen part can run
into another) and `INBOUND_SOURCE_KEY` are exported.
- **Inbound source envelope: admission facts.** An ordinary `channels/incoming`
message or `push/event` now has its envelope frozen at admission, before it
is queued or acknowledged, so a rename or rebind while it waits cannot
change it. Every channel and unscoped envelope names the admission `lane`
(`channels/incoming` or `push/event`), and one that came through RFC-006
coalesced admission has `coalesced: true` (others have no `coalesced`
field): facts a consumer needs to know what an `eventId` is worth.
`readInboundSource` validates every field it returns.
10 changes: 10 additions & 0 deletions changelog.d/mcpl-request-outcome.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
- **`McplRequestError`: why an MCPL request produced no result.** A request
to an MCPL server that fails now rejects with `McplRequestError` (exported),
whose `outcome` says which of three things happened: `not-sent` (refused
before anything was written: the connection was already closed),
`error-response` (the server answered with a JSON-RPC error; the error's
`data` is kept, and its code is in the message), or `no-response` (the request was handed to the
transport and no answer came back: a timeout, or the connection closed
while awaiting; it may or may not have reached the server). Only
`not-sent` proves the server never saw the request. Messages are unchanged,
and it is still an `Error`, so existing handling keeps working.
6 changes: 6 additions & 0 deletions changelog.d/prose-drafts-delivery-outcomes.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- Explicit prose whose delivery definitely fails is held as a bounce draft with its notice, as hybrid envelopes are. A delivery whose outcome is unknown gets the failure marker alone, so its words are not offered for an ordinary resend that might duplicate a post. A failed delivery that is held gets the bounce notice alone, not a second marker.
- An envelope whose `{{unsent}}` names no held draft (the latest bounce draft was sent, set aside or let go) sends nothing, rather than the rest of the envelope. Its other words, if any, are held as a bounce draft; otherwise a short notice says nothing was sent.
- In hybrid and locus mode, a completing turn waits for its queued speech and hybrid holds even when its final response is empty, so its receipt includes that work and its draft notices settle with the same turn.
- Failure markers and traces include a bounded display of the connector's structured error data, such as parts already posted, and the connector's error message is bounded too. Contradictory delivery receipts bound the message id in their diagnostic without changing the receipt's id.
- A synchronous MCPL write rejection reports that the request was not sent and removes its pending state. `channels/open` keeps the same deadline as `channels/publish` when `requestTimeoutMs` is 0 (`publishDeadlineMs`, 60 s by default), so an unanswered open can't hold a delivery for good.
- A hybrid `>>>skip_reply {{unsent}}` in a skip-reply round sets the latest bounce aside while keeping publication private; text in a round with a private think stays uninterpreted, matching explicit mode.
91 changes: 91 additions & 0 deletions changelog.d/prose-drafts.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
- **Held prose drafts: suppressed speech is kept, not destroyed.** Plain
speech that routing holds back is now held as a draft with its exact
words, instead of surviving only as a count: speech suppressed by an
explicit send in the same round (locus and hybrid modes, mid-turn, trailing
and text-only, native and XML tool modes), hybrid prose left without a
destination after a failed envelope, and explicit/hybrid bounces, a
delivery that definitely failed among them (which replace the in-memory
latest-wins clipboard; `{{unsent}}` keeps meaning the latest bounce). Locus
speech whose delivery fails keeps its failure marker, and isn't held. A draft held beside a send keeps its run exactly as
written, indentation and surrounding newlines included; routing still
publishes trimmed segments. Deliberate privacy is never drafted:
`skip_reply` (the tool, or a hybrid `>>>skip_reply` envelope, which keeps
the rest private until a destination is named), a same-round private
`think` in a native round (where that policy applies: XML tool mode routes
such prose as ordinary speech, so a send holds it as a draft), silent turns
and `proseRouting: disabled` narration stay private.
- In hybrid mode a send withholds publication, not what an envelope does: a
held `>>>` envelope moves the routing state as live routing would (a
destination it names becomes the sticky target), `>>>skip_reply {{unsent}}`
still sets the latest bounce aside, and the draft holds the message the
envelope would have published, with `{{unsent}}` expanded under the
re-bounce rule (a bare `{{unsent}}` leaves the bounce as the one draft).
- Drafts live in typed Chronicle records (`RecordJournal`) that do not follow
branch switches: undo, rollback and checkout never change them, and they
survive restarts. Nothing is ever sent from them on its own. Each
resident's 100 newest drafts are kept, and an older one for at least 7
days after anything last happened to it (held, named, attempted, answered,
dismissed). The journal lets such a draft go when it next checkpoints what
it keeps, every 500 entries, so opening it replays only what follows. A request naming a draft that was let go is
refused, and nothing is sent, unless that id has since been given to a newer draft,
which the request then names instead: an id is `d-` and seven characters, so after N more of its resident's drafts a let-go id has come back
with a probability of about N in 2.75 × 10^10.
- A private notice names held drafts at the next tool boundary when the live
stream presents mid-turn messages (a draft holding words copied from one
that may already have been posted is named with that risk and the
`confirmDuplicate` its resend needs). In locus and hybrid modes the
turn-end `[delivered]` receipt names every draft held in the turn, in any
of its streams (a framework retry, or the compile a provider-capacity hold
defers the turn to), by id and by its state at turn end: still held
("held as drafts d-… (not sent …)"), delivered by the resident's resend
(listed where it landed), unconfirmed, or dismissed. A dismissed draft
whose own attempt may already have been posted is named with that risk,
and counts like an unconfirmed one: a receipt that names one, with nothing
confirmed delivered, reads "nothing confirmed", not "nothing". Explicit
mode writes no receipt: its drafts are named by their bounce and refusal
notices. A draft counts as named while a message naming it is on the
current branch, so one no notice reached (a crash, an aborted turn), or
whose notices a rollback or branch move took away, is named at the next
turn's start: every such open draft by id, the newest eight with its state
and why it was held. That catch-up reads the recent history (the newest
500 messages): it records a notice it finds there, including one that
landed after its turn had ended, and it leaves out the drafts of a turn
still open and those a notice waiting to be written names. Notices
name drafts and say how to resend or dismiss them, and quote none of their
words; the `drafts` tool's `read` shows the words, and its `list` a
preview of each.
- A draft is never published except by its resident's explicit resend (or
`{{unsent}}`), and only that resident can list or act on it. Its notices go
into the resident's own history, which residents sharing one message slot
already share, as they share the assistant turn the words came from.
- New `drafts` tool (whenever MCPL channels are configured): `list`, `read`,
`resend` and `dismiss`, scoped to the calling resident. Every such
resident's tool list gains it, so each one's first request after upgrading
misses the prompt cache once. `resend` publishes
drafts verbatim, in order, to one destination through the normal publish
path, journaling each attempt durably before the request leaves; it counts
as an explicit send. A delivered draft returns its historical receipt
instead of sending again; a draft with any attempt whose outcome is
unknown (and none confirmed) needs `confirmDuplicate: true`, and a resend
owns its drafts until it finishes, re-checking each before it is sent, and
a draft it doesn't attempt is told why the batch stopped. Dismissing a
draft (with the tool or `>>skip_reply {{unsent}}`) is refused while that
draft's attempt is out, since a dismissal can't call back words already on
their way; the resident is told to wait for that result. A draft only
queued in a resend can still be dismissed, and is then not sent. A
dismissal of a draft that may already have been posted names that risk.
Unused fields may be passed as null.
- `{{unsent}}` whose draft is being sent, queued in a resend, or unconfirmed
sends nothing, and the envelope's own words are held as a bounce draft
with `{{unsent}}` written out and the risk carried, so nothing the
resident wrote has to be typed again. If the journal refuses that draft,
the notice says the words remain only in the history.
- Prose segments also break at XML tool mode's refused attempts and their
notices (`tool_attempt`, `tool_notice`): the words before an all-refused
call and after its notice are two messages, routed or held separately.
- `ChannelRegistry.deliverSpeech` (routeSpeech with its `PublishOutcome`) and
`ChannelRegistry.resolveDestination` are new. `routeSpeech` now delegates
to `deliverSpeech` and returns null unless delivery was confirmed, so a
missing receipt and a channel id two servers share now return null (see
the publish-outcomes entry).
- The bounce notice now goes to the bouncing resident rather than the primary.
42 changes: 42 additions & 0 deletions changelog.d/publish-outcomes.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
- **Speech delivery reports what a publish actually established.** Plain
speech now goes through one publish executor (`ChannelRegistry.publish`,
returning a `PublishOutcome`: `delivered`, `failed` or `unknown`, with the
destination the registry resolved). Only a connector's `delivered: true`
confirms a post; a missing or malformed receipt is no longer counted as
delivered (#163's rule). `failed` means nothing was posted: refused before
dispatch, or `delivered: false` naming no message. An error response, a
timeout or a lost connection after dispatch is `unknown`, keeping the
connector's own words (a multi-part send may already be partly posted), and
so is a contradictory receipt (`delivered: false` beside a message id,
valid or not).
The resident's `[discord-send-failed]` marker now says which: "could not be
delivered … Nothing was posted" for a failure, and "was not confirmed … may
or may not have been posted, so sending it again could post it twice" for
an uncertain outcome. The marker's metadata and the
`mcpl:speech-route-failed` trace carry `outcome`. A channel id registered
by more than one MCPL server is refused rather than routed through
whichever server registered it first, and a supplied but empty or
non-string server selector is refused rather than read as omitted. A send
refused for its server or grant opens no closed channel. A
`channels/publish` request always has a deadline: with `requestTimeoutMs`
0 it keeps the 60 s default, since its outcome is told to the resident and
recorded for its draft; its expiry reads as unknown.
`channel_publish`, and the subconscious's `speak_in_channel`, which shares
its path, answer by the same rule. Before, they answered success with
`{ delivered: true }` for a missing receipt and never read `delivered`.
Now only `delivered: true` is success. `delivered: false` naming no
message, or a request refused before it was written, is an error saying
nothing was posted. Anything else is an error saying the publish wasn't
confirmed and that sending it again could post it twice. That answer, a
draft resend's line for a send that wasn't confirmed, and the `drafts`
tool's read of that attempt carry the connector's data, bounded, when it
sent some. `channel_publish` and
`speak_in_channel` still resolve a channel id by its first registered
match and post into a closed channel without opening it, as before.

If you run a connector that answers `channels/publish` with `{}` or no
receipt, which earlier versions counted as delivered, every publish
through it now reads as unconfirmed. The resident is told of each reply
that it wasn't confirmed, and gets an error for each `channel_publish` and
`speak_in_channel` call, each saying that sending it again could post it
twice. A connector confirms a post by answering `delivered: true`.
39 changes: 33 additions & 6 deletions docs/explicit-prose-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,42 @@ A prose segment's FIRST line may carry a routing prefix:
"keep going" ability tool turns have — wanted for robotics-like loops where
an end-of-turn pause is undesirable.
- `{{unsent}}` anywhere in the body is replaced at delivery with the
retained undelivered text (see below), enabling verbatim resend without retyping.
latest bounced text, held as a draft (see below), enabling verbatim resend
without retyping.

## The bounce (unprefixed prose)

Unprefixed prose with no sticky target is **never delivered**. It is retained
per-agent (latest-wins) and a system notice is appended telling the
agent how to resend: `>>#channel {{unsent}}`. The notice requests inference so
the resend can happen immediately — capped at 2 consecutive bounce-wakes per
agent (then notices append without waking, breaking any loop).
Unprefixed prose with no sticky target is **never delivered**. It is held as a
draft (src/prose-drafts.ts) — never published except by the agent's own
resend — and a system notice naming the draft tells
the agent how to resend: `>>#channel {{unsent}}`, or the `drafts` tool by id.
The notice requests inference so the resend can happen immediately — capped at
2 consecutive bounce-wakes per agent (then notices append without waking,
breaking any loop).

`{{unsent}}` means the latest bounce while that draft is open (latest-wins, as
the clipboard it replaced was), and `>>skip_reply {{unsent}}` dismisses it.
Earlier bounces stay in the agent's drafts, by id, until resent, dismissed or
let go (an agent's 100 newest drafts are always kept, and an older one for 7
days after anything last happened to it); drafts survive restarts and branch
moves, and a delivery through `{{unsent}}` is recorded on the draft like any
resend.

When the latest bounce may already have been posted (an attempt's outcome is
unknown), or is being sent or queued in a resend right now, what happens
depends on the route:
- **Delivery** (`>>#channel … {{unsent}} …`): nothing is sent. The agent is
told. While the bounce is being sent or queued, it is told to wait for that
result; once the bounce may already have been posted, it can resend it with
the `drafts` tool's `confirmDuplicate`, though that could post it twice.
The envelope's own words are held as a new bounce draft, with `{{unsent}}`
written out and the risk carried, so that draft too can be resent only
with `confirmDuplicate`. A bare `{{unsent}}` holds nothing new: its words
already are the bounce.
- **Hold** (an envelope that bounces, or one an explicit send held back): the
bounce's words are copied into the new draft with `{{unsent}}` written out,
and the draft carries the same risk (`inheritedRisk`), so it reads
unconfirmed until a confirmed delivery.

## What explicit mode retires (for that agent)

Expand Down
Loading
Loading