From e45f6b4301814bd90f54208072fe02a075633ba4 Mon Sep 17 00:00:00 2001 From: Joakim Persson Date: Mon, 7 Sep 2026 21:42:58 +0200 Subject: [PATCH] feat(mailbox): surface replies that CLOSE this device's own asks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mailbox could report what this device OWES, and structurally nothing else. deriveOwed() queries the log with status:"open", and a reply that closes an ask is by definition not open — so a peer answering my delegation was invisible to it at every poll, forever, not just late. Measured 2026-09-07: emb-7kj4vr4g closed correlation v1813-client-rollout-emb with a task.reply at status=applied. Nothing was announced. The feature was CORRECT by its own definition ("owed" = "you must reply", and nothing was owed) and wrong by the operator's, who asked why no notification arrived. The single most useful thing a fleet can say to a human is "the thing you asked for is done" — and that was the one category it could not say. Independent corroboration that this was a real gap and not a preference: emb's own reply ends "The amd64 narrowing is filed as a drawer as well, SINCE A TERMINAL EVENT REACHES NO MAILBOX." A peer had already diagnosed the hole and was routing around it by hand. deriveClosed(): my task.requests (correlation + directed) joined against inbound events with NO status filter, keeping the newest TERMINAL_STATUS reply per correlation. Verified by computing the exact predicate over the two real events: the new path yields evt_20260907T183143 (applied); the old status:"open" query yields 0. Announced once each (never resurfaced — a finished ask is not a nag), and the copy warns that a peer who did the work is the likeliest party to have found your premise wrong. Here both premises were wrong: mine that emb was amd64, and emb's that tor-ms22 was therefore the last candidate. Deliberate asymmetry, documented at the call site: a broadcast is excluded from the owed set (it owes nobody) but allowed to CLOSE, since a peer answering on my correlation_id is news however widely it was addressed. --- extensions/pi/mempalace.ts | 122 ++++++++++++++++++++++++++++++++----- 1 file changed, 106 insertions(+), 16 deletions(-) diff --git a/extensions/pi/mempalace.ts b/extensions/pi/mempalace.ts index 4f48fc8..0162f72 100644 --- a/extensions/pi/mempalace.ts +++ b/extensions/pi/mempalace.ts @@ -1101,6 +1101,61 @@ export default async function mempalaceExtension(pi: ExtensionAPI) { } } + /** + * Terminal replies to asks THIS device sent. NOT work — news. + * + * Why this is a second query rather than a widened deriveOwed(): deriveOwed() + * asks the log for `status: "open"`, and a reply that CLOSES an ask is by + * definition not open, so it is structurally invisible to that filter — no + * amount of polling or waiting could ever have surfaced it. + * + * Measured 2026-09-07: emb-7kj4vr4g closed a v1.8.13 rollout ask with a + * task.reply at status=applied, and the operator reasonably expected to be + * told. The mailbox stayed silent and was CORRECT to — "owed" means "you must + * reply", and nothing was owed. But the single most useful thing a fleet can + * tell a human is "the thing you asked for is done" (here: done, AND your + * premise was wrong), and that was the one category the feature could not + * report. Silence was right by its own definition and wrong by the user's. + */ + async function deriveClosed(): Promise { + if (!mailboxEnabled || !available) return []; + try { + const [mineRaw, inboundRaw] = await Promise.all([ + client.callTool("mempalace_event_list", { from_agent: mailboxAddress, limit: 100 }), + // NO status filter, deliberately: that omission is the entire fix. + client.callTool("mempalace_event_list", { to_agent: mailboxAddress, limit: 50 }), + ]); + // Correlations this device opened as a DIRECTED ask. A broadcast owes + // nobody a reply, so it cannot be closed by one either. + const myAsks = new Map(); + for (const e of parseEvents(mineRaw)) { + if (!e.correlation_id || !e.to_agent) continue; + if (e.to_agent === "*" || e.to_agent === mailboxAddress) continue; + if (e.type !== "task.request") continue; + myAsks.set(e.correlation_id, e); + } + if (myAsks.size === 0) return []; + // Newest terminal reply per correlation only. A peer that appends + // applied-then-superseded should cost one line, not a wall of them. + const best = new Map(); + for (const e of parseEvents(inboundRaw)) { + if (e.from_agent === mailboxAddress) continue; // cannot inform myself + const cid = e.correlation_id; + if (!cid || !myAsks.has(cid)) continue; + // NOTE the deliberate asymmetry with deriveOwed: a broadcast is excluded + // there (it owes nobody) but allowed here, because a peer answering on MY + // correlation_id is news to me regardless of how widely it was addressed. + if (!TERMINAL_STATUS.has((e.status ?? "").toLowerCase())) continue; + const prev = best.get(cid); + if (!prev || isStrictlyAfter(e, prev)) best.set(cid, e); + } + return [...best.values()]; + } catch { + // Same contract as deriveOwed: a mailbox read never breaks a session. + return []; + } + } + const relAge = (iso: string | undefined): string => { if (!iso) return "age unknown"; const then = Date.parse(iso); @@ -1152,6 +1207,13 @@ export default async function mempalaceExtension(pi: ExtensionAPI) { "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."; + const CLOSED_NOTE = + "NO ACTION IS OWED on these — they are replies to asks THIS device sent, shown " + + "once each because 'the thing you asked for is done' is news you wanted and the " + + "owed-set could never carry it. Read the reply before assuming your original ask " + + "was right: a peer that did the work is the most likely party to have found your " + + "premise wrong."; + // 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 @@ -1233,23 +1295,40 @@ export default async function mempalaceExtension(pi: ExtensionAPI) { lastMailboxPollAt = Date.now(); void (async () => { try { - const owed = await deriveOwed(); + const [owed, closed] = await Promise.all([deriveOwed(), deriveClosed()]); const now = Date.now(); - const due = owed.filter((e) => { - if (!e.id) return false; - const last = surfaced.get(e.id); - return last === undefined || now - last >= mailboxResurfaceMs; - }); - if (due.length === 0) return; // silence is the correct output here - for (const e of due) if (e.id) surfaced.set(e.id, now); + const unseen = (list: LogEvent[]): LogEvent[] => + list.filter((e) => { + if (!e.id) return false; + const last = surfaced.get(e.id); + return last === undefined || now - last >= mailboxResurfaceMs; + }); + const due = unseen(owed); + // Closing replies are announced ONCE and never resurface: an ask you + // already know is finished is not a nag, and re-announcing it hourly is + // the train-the-reader-to-ignore-it failure this window exists to stop. + const newsRaw = closed.filter((e) => e.id && !surfaced.has(e.id)); + if (due.length === 0 && newsRaw.length === 0) return; // silence is correct + for (const e of [...due, ...newsRaw]) if (e.id) surfaced.set(e.id, now); + const blocks: string[] = []; + if (due.length > 0) { + blocks.push( + `${due.length} directed ask(s) addressed to "${mailboxAddress}" with no ` + + `terminal reply from this device. ${OWED_HOWTO}\n\n${formatOwed(due)}`, + ); + } + if (newsRaw.length > 0) { + blocks.push( + `${newsRaw.length} repl(y|ies) CLOSING an ask this device sent. ` + + `${CLOSED_NOTE}\n\n${formatOwed(newsRaw)}`, + ); + } pi.sendMessage( { customType: "mempalace-mailbox", 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), + `MemPalace logstream mailbox.\n\n${QUEUED_NOTE}\n\n` + + blocks.join("\n\n---\n\n"), display: true, }, // "steer" is queue-on-arrival, NOT an interrupt: at agent_settled the @@ -1266,9 +1345,11 @@ export default async function mempalaceExtension(pi: ExtensionAPI) { // 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`; + const parts = [ + due.length > 0 ? `${due.length} ask(s) owed` : "", + newsRaw.length > 0 ? `${newsRaw.length} closed` : "", + ].filter(Boolean); + const summary = `${parts.join(" + ")} for ${mailboxAddress} — send any message to handle`; try { if (ctx?.hasUI) ctx.ui.notify(`MemPalace mailbox: ${summary}`, "info"); } catch { @@ -1309,7 +1390,7 @@ export default async function mempalaceExtension(pi: ExtensionAPI) { // off a filter. deriveOwed() swallows its own failures and returns [], so a // broken palace costs a missing section, never a broken wake-up. if (mailboxEnabled) { - const owed = await deriveOwed(); + const [owed, closed] = await Promise.all([deriveOwed(), deriveClosed()]); if (owed.length > 0) { // Record what this injection showed, so the first mid-session poll does // not re-announce the identical list minutes later. Without this the @@ -1323,6 +1404,15 @@ export default async function mempalaceExtension(pi: ExtensionAPI) { `this device. ${OWED_HOWTO}\n\n${formatOwed(owed)}`, ); } + const news = closed.filter((e) => e.id && !surfaced.has(e.id)); + if (news.length > 0) { + const now = Date.now(); + for (const e of news) if (e.id) surfaced.set(e.id, now); + sections.push( + `## logstream mailbox (${news.length} closed — no action owed)\n\n` + + `${CLOSED_NOTE}\n\n${formatOwed(news)}`, + ); + } } if (sections.length === 0) return;