diff --git a/docs/rfc-003-coordination-log.md b/docs/rfc-003-coordination-log.md index baf0f15..530e3db 100644 --- a/docs/rfc-003-coordination-log.md +++ b/docs/rfc-003-coordination-log.md @@ -319,7 +319,14 @@ The mid-session poll delivers with `deliverAs: "steer"` and **deliberately no `t **Measured 2026-08-26:** a delivery landed in a session window at ~20:50Z and sat there, visibly unreacted-to, until the operator asked "do I have to nudge you for you to read it?" — which is precisely the question this design produces. The delay is not the agent choosing to ignore its mailbox; between the poll and the next turn there is no inference at all. -The consequence is a **UX gap, not a defect**: from the outside, an inbound ask plus an idle agent reads as neglect. The delivered text explains how to *close* an ask but says nothing about *when* it will be seen — so the one reader who needs that fact, the human watching the window, is the one not told. Fixes that do not wake a model: say the queued-until-next-turn semantics in the delivered text itself; notify at poll time (`ctx.ui.notify` / desktop toast) so the human knows a nudge is worthwhile; or an opt-in `MEMPALACE_MAILBOX_TRIGGER=1`, default off, for anyone who does want a turn started. +The consequence is a **UX gap, not a defect**: from the outside, an inbound ask plus an idle agent reads as neglect. The delivered text explained how to *close* an ask but said nothing about *when* it would be seen — so the one reader who needed that fact, the human watching the window, was the one not told. + +**✅ Addressed 2026-08-26 (toolkit `extensions/pi/mempalace.ts`), without touching the no-`triggerTurn` decision.** Two changes, in the order their value is realised: + +1. **A delivery note in the message itself** — it says this is a queued message, that nothing woke the agent, and that any message starts the turn that will handle it. Costs nothing, changes no behaviour, and converts "why is it ignoring me?" into "right, I nudge it." +2. **A notification at poll time**, because the note only reaches someone already looking, and the case that actually loses an ask is nobody looking. `MEMPALACE_MAILBOX_NOTIFY` unset → in-TUI `ctx.ui.notify` (the surface `session_start` already uses); `=desktop` → additionally a terminal-native notification (Kitty `OSC 99`, else `OSC 777`), which is how a ping escapes a container with no `notify-send`, DBus or host access — the escape sequence is interpreted by the terminal emulator on the human's machine; `=0` → silent. Title and body are stripped of `;` and control bytes so a payload cannot forge an OSC field or terminate the sequence early. It fires only when something is due, because a ping on an empty poll trains its reader to ignore it. + +Still deliberately **not** done: `MEMPALACE_MAILBOX_TRIGGER=1` (opt-in, default off) to start a turn on arrival. Worth revisiting once notification is proven in daily use — but a machine that auto-turns on inbound events writes to a shared log with nobody watching, and §7.1 (no idempotency guard) is the reason to be slow about it. ### 7.12 The mailbox is an obligation channel, so a report addressed to you is never delivered @@ -358,7 +365,7 @@ Replication exists in code — `version_vector()`, `list_ops()`, `apply_remote_e 5. **Should `GET /logstream/events` exist?** A read-only HTTP tail would let non-MCP consumers (dashboards, CI) follow a stream without an MCP client. Today they must hold SSE or speak MCP. 6. **Undocumented limits.** The 64 KiB `metadata` cap and the lowercase-`type` regex are enforced server-side but absent from the tool schemas (§3.1). Document, or relax. 7. **Agent-name registry.** Nothing prevents two devices sharing one `from_agent` (§7.7). A warning at append time would be cheap. -8. **Does an arriving ask deserve a notification, and does it ever deserve a turn?** (§7.11) The no-`triggerTurn` decision stands; the open part is the cheap half — stating the queued-until-next-turn semantics in the delivered text, and whether to notify the human at poll time so a nudge is known to be worthwhile. +8. ~~**Does an arriving ask deserve a notification?**~~ **Done 2026-08-26** — delivery note plus `MEMPALACE_MAILBOX_NOTIFY` (§7.11). What stays open is the narrower question: should `desktop` be the *default* rather than opt-in, and does an arriving ask ever deserve a turn (`MEMPALACE_MAILBOX_TRIGGER`)? 9. **Is there a delivery path for news rather than obligations?** (§7.12) Today the only delivered shape is a directed open ask. Options: leave it pull-only and rely on the paired drawer, or give informational events a distinct low-priority surface at wake-up ("3 reports since your last session") separate from the owed set. ### 9.1 Retention — decided direction (2026-08-26) diff --git a/extensions/pi/README.md b/extensions/pi/README.md index 7c00856..bc535ad 100644 --- a/extensions/pi/README.md +++ b/extensions/pi/README.md @@ -316,6 +316,37 @@ waits for the agent to think of asking. Two delivery points, both fail-silent: `triggerTurn` — waking the model on inbound fleet traffic is a much larger behavioural change than auto-delivery. +**Which means a human is the trigger, and the mailbox now says so.** Measured +2026-08-26 on two devices: a delivery lands, the agent is idle, nothing happens, +and the operator asks *"do I have to nudge you for you to read this?"*. Yes — +because between the poll and the next turn no inference is running. The old text +explained how to *close* an ask and never said when it would be *seen*, so the +only reader who needed that fact was the one not told. Two additions, neither of +which touches the no-`triggerTurn` decision: + +- **A delivery note in the message itself** — states that this is a queued + message, that nothing woke the agent, and that any message starts the turn + that handles it. Free, and aimed at the human reading the window. +- **A notification at poll time**, because the note only helps someone who is + already looking, and the case that loses an ask is nobody looking: + + | `MEMPALACE_MAILBOX_NOTIFY` | Behaviour | + |---|---| + | *unset* (default) | in-TUI `ctx.ui.notify`, the same surface `session_start` already uses | + | `desktop` | additionally a terminal-native notification — Kitty `OSC 99`, else `OSC 777` (iTerm2, WezTerm, Ghostty, rxvt-unicode) | + | `0` / `off` | silent; mailbox still delivers | + + The `desktop` path is how a notification escapes a container without + `notify-send`, DBus or any host access: the escape sequence is written to stdout + and interpreted by the terminal emulator on the human's own machine. It is + opt-in because writing raw escapes is a behaviour change on a shared machine, + not because it is unreliable. Title and body are stripped of `;` and control + bytes, so a payload can neither forge an OSC field nor end the sequence early. + + It fires **only when something is due** — the same condition as the delivery + itself. A ping on an empty poll would train its reader to ignore it, which is + the failure this whole feature exists to reverse. + Owed-ness is **derived, never read off a field**, because `event_ack` appends and `status` is written once: a directed `open` event matches the mailbox query *forever*, answered or not. Two calls (`to_agent= status=open`, and @@ -340,7 +371,8 @@ ask demonstrably reaches no owed set, giving that documented anti-pattern teeth. Gated on `MEMPALACE_PI_DEVICE` **and** `MEMPALACE_REMOTE_URL` (the same pair as the stamper, since an unstamped client has no address to be reached at), and -disabled outright with `MEMPALACE_MAILBOX=0`. Inert on a solitary palace: no +disabled outright with `MEMPALACE_MAILBOX=0` (notifications alone with +`MEMPALACE_MAILBOX_NOTIFY=0`). Inert on a solitary palace: no calls, no injection. Delivery is the mechanism; the *norms* — what a reply owes, and that only a terminal event closes a thread — remain normative in the *consumer* skill (`~/.agents/skills/mempalace/SKILL.md`). **This file documents diff --git a/extensions/pi/mempalace.ts b/extensions/pi/mempalace.ts index 38bf380..18c835c 100644 --- a/extensions/pi/mempalace.ts +++ b/extensions/pi/mempalace.ts @@ -1138,6 +1138,20 @@ export default async function mempalaceExtension(pi: ExtensionAPI) { "(applied/superseded/failed/blocked). An ack alone does NOT clear it, and neither " + "does claimed/ready — those deliberately keep it visible as taken-but-unfinished."; + // Written for the ONE reader the rest of this text ignores: the human watching + // the window. Measured 2026-08-26 on two devices — a delivery lands, the agent + // is idle, nothing happens, and the operator asks "do I have to nudge you for + // you to read this?". Yes, and it is by design (see the deliverAs comment + // below): at agent_settled no inference is running, so the text is queued for + // the next turn. The message explained how to CLOSE an ask but never when it + // would be SEEN, so the person who needed that fact was the only one not told. + // Cheapest possible fix, and it changes no behaviour: say so in the text. + const QUEUED_NOTE = + "DELIVERY NOTE — this is a QUEUED message, not an action: it arrived while this " + + "agent was idle, so no model call was made and nothing woke it. It is read at the " + + "start of the next turn. If you are the human watching and want it handled now, " + + "send any message to start a turn; the agent is not ignoring the ask, it is not running."; + // Mid-session mailbox. Tier 1 (the wake-up injection below) owns the first // look; this exists because arrivals are bursty and correlate with our own // activity — measured 2026-08-26, 11 of 22 events on this log landed inside a @@ -1149,7 +1163,51 @@ export default async function mempalaceExtension(pi: ExtensionAPI) { // the owed set holds something not already surfaced. Re-announcing the same // ask every poll is precisely how a channel teaches its reader to ignore it, // which is the failure this whole feature exists to reverse. - pi.on("agent_settled", async () => { + // --- A: tell the human at poll time (RFC 003 §7.11) ----------------------- + // + // B (the QUEUED_NOTE above) fixes the confusion of someone who IS looking at + // the window. It does nothing for the case that actually loses an ask: nobody + // is looking. The delivered text is queued for a turn that only a human starts, + // so an ask can wait indefinitely on an idle session while its recipient makes + // coffee. A notification at poll time is the only part of this feature that + // reaches a person who is not watching. + // + // Two surfaces, deliberately split by risk: + // default in-TUI ctx.ui.notify, exactly as session_start already does. + // Zero new output channels; visible only if you are looking. + // =desktop additionally emit a terminal-native notification, reusing + // the detection the fleet's own notify.ts already proves in + // this harness (Kitty OSC 99 / Windows toast / OSC 777). This + // escapes the container without notify-send, DBus or any host + // access: the escape sequence is interpreted by the terminal + // emulator on the human's machine. Opt-in because writing raw + // escapes to stdout is a behaviour change on shared machines, + // not because it is unreliable. + // =0 silent, for anyone who wants the mailbox without pings. + // + // Fired only when something is actually due, i.e. the same condition as the + // delivery itself: a notification that fires on an empty poll would teach its + // reader to ignore it, which is the failure this whole feature exists to undo. + const mailboxNotifyMode = (process.env.MEMPALACE_MAILBOX_NOTIFY ?? "").trim().toLowerCase(); + const mailboxNotifyEnabled = mailboxNotifyMode !== "0" && mailboxNotifyMode !== "off"; + const mailboxNotifyDesktop = mailboxNotifyMode === "desktop"; + + /** Terminal-native notification. Best-effort; never throws into a handler. */ + const notifyTerminal = (title: string, body: string): void => { + try { + const esc = (s: string): string => s.replace(/[\x00-\x1f\x7f;]/g, " "); + if (process.env.KITTY_WINDOW_ID) { + process.stdout.write(`\x1b]99;i=1:d=0;${esc(title)}\x1b\\`); + process.stdout.write(`\x1b]99;i=1:p=body;${esc(body)}\x1b\\`); + return; + } + process.stdout.write(`\x1b]777;notify;${esc(title)};${esc(body)}\x07`); + } catch { + /* a notification is never worth an exception */ + } + }; + + pi.on("agent_settled", async (_event, ctx) => { if (!mailboxEnabled || !available) return; if (!wokeUp) return; // the wake-up injection has not run yet; it does look #1 if (Date.now() - lastMailboxPollAt < mailboxPollMs) return; @@ -1173,6 +1231,7 @@ export default async function mempalaceExtension(pi: ExtensionAPI) { content: `MemPalace logstream mailbox: ${due.length} directed ask(s) addressed to ` + `"${mailboxAddress}" with no terminal reply from this device. ${OWED_HOWTO}\n\n` + + `${QUEUED_NOTE}\n\n` + formatOwed(due), display: true, }, @@ -1185,6 +1244,21 @@ export default async function mempalaceExtension(pi: ExtensionAPI) { // than auto-delivery and is not what was approved. { deliverAs: "steer" }, ); + // AFTER the queueing call, so a notification can never be the only + // thing that happened: the message is in the thread first, then we + // point at it. Wording names the nudge explicitly, because "you have + // mail" without "press a key" reproduces the exact confusion B fixes. + if (mailboxNotifyEnabled) { + const summary = + `${due.length} directed ask(s) queued for ${mailboxAddress} — ` + + `send any message to handle`; + try { + if (ctx?.hasUI) ctx.ui.notify(`MemPalace mailbox: ${summary}`, "info"); + } catch { + /* ignore: UI may be gone by the time the poll resolves */ + } + if (mailboxNotifyDesktop) notifyTerminal("MemPalace mailbox", summary); + } } catch { /* best-effort delivery: never break a turn over a mailbox read */ }