From b3d986791fcffa655a62f242b4608f27ce6a0fc1 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Thu, 30 Jul 2026 21:28:16 -0400 Subject: [PATCH] =?UTF-8?q?principal=20names=20ride=20every=20prompt=20?= =?UTF-8?q?=E2=80=94=20<@id>=20(name)=20from=20the=20adapter's=20roster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent-tools 0.5.0 resolves user ids to display names at ingestion (users.list prewarm + lazy users.info). The router persists the name in the event payload, inbox delivery and the ear's thread tails render who() = <@id> (name), so neither mind nor ear ever judges who-is- talking-to-whom from a bare id again (2026-07-30: the ear attributed a teammate's reply to her and recorded his question as her debt). Events from before names existed render exactly as before — the name is optional everywhere. NOTE: fresh bun install needs @bevyl-ai/agent-tools 0.5.0 on npm (publish pending operator OTP); the dep bump is committed ahead of it. Co-Authored-By: Claude Fable 5 --- package.json | 2 +- src/adapter/router.ts | 2 +- src/ledger/inbox.ts | 14 +++++++++----- src/service.ts | 11 +++++++++-- test/ear.test.ts | 12 +++++++----- 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index ddb03d5..0064ac9 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "check": "bun run typecheck && bun run lint && bun test" }, "dependencies": { - "@bevyl-ai/agent-tools": "^0.4.0" + "@bevyl-ai/agent-tools": "^0.5.0" }, "devDependencies": { "@types/bun": "^1.2.0", diff --git a/src/adapter/router.ts b/src/adapter/router.ts index 9889661..2b1fd1c 100644 --- a/src/adapter/router.ts +++ b/src/adapter/router.ts @@ -94,7 +94,7 @@ export function routeMessage(db: Database, clock: Clock, msg: RawMessage, opts: db.query( `INSERT INTO events (id, dedup_key, kind, identity_id, venue_id, thread_root_id, principal_id, payload, received_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run(eventId, dedupKey, eventKind, identityId, msg.venueId, msg.threadRootTs, msg.principalId, JSON.stringify({ text: msg.text, ts: msg.ts, isBot: msg.isBot, ...(addressMode ? { addressMode } : {}), ...(msg.files?.length ? { files: msg.files } : {}) }), now); + ).run(eventId, dedupKey, eventKind, identityId, msg.venueId, msg.threadRootTs, msg.principalId, JSON.stringify({ text: msg.text, ts: msg.ts, isBot: msg.isBot, ...(msg.principalName ? { principalName: msg.principalName } : {}), ...(addressMode ? { addressMode } : {}), ...(msg.files?.length ? { files: msg.files } : {}) }), now); } catch { return { kind: "duplicate" }; } diff --git a/src/ledger/inbox.ts b/src/ledger/inbox.ts index 8195a44..bb9d2a6 100644 --- a/src/ledger/inbox.ts +++ b/src/ledger/inbox.ts @@ -13,6 +13,9 @@ export interface InboxMessage { venueId: string | null; threadRootId: string | null; principalId: string | null; + // The principal's human name as the adapter resolved it at ingestion (absent on events from + // before names existed, or when the roster missed). Rendering only; principalId stays the key. + principalName?: string; text: string; ts: string | null; receivedAt: string; @@ -42,7 +45,7 @@ export function messagesAfter(db: Database, identityId: string, afterRowid: numb ) .all(identityId, cursor, limit) as { rowid: number; id: string; kind: InboxMessage["kind"]; venue_id: string | null; thread_root_id: string | null; principal_id: string | null; payload: string; received_at: string }[]; return rows.map((r) => { - const p = JSON.parse(r.payload) as { text?: string; ts?: string; addressMode?: InboxMessage["addressMode"]; files?: InboxMessage["files"] }; + const p = JSON.parse(r.payload) as { text?: string; ts?: string; principalName?: string; addressMode?: InboxMessage["addressMode"]; files?: InboxMessage["files"] }; return { rowid: r.rowid, id: r.id, @@ -53,6 +56,7 @@ export function messagesAfter(db: Database, identityId: string, afterRowid: numb text: p.text ?? "", ts: p.ts ?? null, receivedAt: r.received_at, + ...(p.principalName ? { principalName: p.principalName } : {}), ...(p.addressMode ? { addressMode: p.addressMode } : {}), ...(p.files?.length ? { files: p.files } : {}), }; @@ -63,17 +67,17 @@ export function messagesAfter(db: Database, identityId: string, afterRowid: numb // live threads that delta touches". A mid-thread "you" is undecidable without the messages // around it (live 2026-07-30: a one-line batch read an offer to a teammate as aimed at her). // Root match as in threads.ts: a reply carries thread_root_id, the parent is its own ts. -export function threadTailBefore(db: Database, identityId: string, venueId: string, threadRootId: string, throughRowid: number, limit = 8): { principalId: string | null; text: string }[] { +export function threadTailBefore(db: Database, identityId: string, venueId: string, threadRootId: string, throughRowid: number, limit = 8): { principalId: string | null; principalName?: string; text: string }[] { const rows = db .query( - `SELECT principal_id, json_extract(payload, '$.text') AS text FROM events + `SELECT principal_id, json_extract(payload, '$.text') AS text, json_extract(payload, '$.principalName') AS name FROM events WHERE identity_id = ? AND venue_id = ? AND rowid <= ? AND kind IN ('addressed_message','observed_message') AND (thread_root_id = ? OR json_extract(payload, '$.ts') = ?) ORDER BY rowid DESC LIMIT ?`, ) - .all(identityId, venueId, throughRowid, threadRootId, threadRootId, limit) as { principal_id: string | null; text: string | null }[]; - return rows.reverse().map((r) => ({ principalId: r.principal_id, text: r.text ?? "" })); + .all(identityId, venueId, throughRowid, threadRootId, threadRootId, limit) as { principal_id: string | null; text: string | null; name: string | null }[]; + return rows.reverse().map((r) => ({ principalId: r.principal_id, ...(r.name ? { principalName: r.name } : {}), text: r.text ?? "" })); } export function advanceCursor(db: Database, identityId: string, deliveredRowid: number): void { diff --git a/src/service.ts b/src/service.ts index 6160fba..5a135f9 100644 --- a/src/service.ts +++ b/src/service.ts @@ -45,6 +45,13 @@ import { createLogger, type Logger } from "./log"; const ATTENTION_MAX_AGE_MS = 48 * 60 * 60 * 1000; const ATTENTION_PROMPT_CAP = 5; +// A speaker the model can actually place: the mention (id, still the key for replies and +// memory) plus the human name the adapter resolved at ingestion. Bare ids made the ear judge +// who-is-talking-to-whom blind (live 2026-07-30: it attributed a teammate's reply to her). +function who(p: { principalId: string | null; principalName?: string }): string { + return `<@${p.principalId ?? "?"}>${p.principalName ? ` (${p.principalName})` : ""}`; +} + // A delivered inbox message, verbatim, with the coordinates she needs to reply into or react // to it: venue, thread root, and the message's own ts. function inboxLine(m: InboxMessage): string { @@ -53,7 +60,7 @@ function inboxLine(m: InboxMessage): string { const files = m.files?.length ? ` [attached: ${m.files.map((f) => `${f.name}${f.mimetype ? ` (${f.mimetype})` : ""}${f.urlPrivate ? ` url_private=${f.urlPrivate}` : ""}`).join(", ")}]` : ""; - return `[<#${m.venueId}>${m.threadRootId ? ` thread=${m.threadRootId}` : ""} ts=${m.ts}] <@${m.principalId ?? "?"}>: ${m.text.slice(0, 2500)}${files}`; + return `[<#${m.venueId}>${m.threadRootId ? ` thread=${m.threadRootId}` : ""} ts=${m.ts}] ${who(m)}: ${m.text.slice(0, 2500)}${files}`; } // A mention or DM is spoken TO her; everything else in a batch (thread chatter, held observed @@ -478,7 +485,7 @@ export class Service { .map((m) => { const tail = threadTailBefore(this.d.db, identityId, m.venueId!, m.threadRootId!, cursor); if (tail.length === 0) return null; - return `earlier in <#${m.venueId}> thread=${m.threadRootId} (already heard — so you can tell who is talking to whom):\n${tail.map((t) => ` <@${t.principalId ?? "?"}>: ${t.text.slice(0, 300)}`).join("\n")}`; + return `earlier in <#${m.venueId}> thread=${m.threadRootId} (already heard — so you can tell who is talking to whom):\n${tail.map((t) => ` ${who(t)}: ${t.text.slice(0, 300)}`).join("\n")}`; }) .filter((b) => b !== null) .join("\n\n"); diff --git a/test/ear.test.ts b/test/ear.test.ts index a303c23..edd2ca6 100644 --- a/test/ear.test.ts +++ b/test/ear.test.ts @@ -450,16 +450,18 @@ describe("what the prompts carry", () => { if (verdict) await verdict.run({ decision: "hold", why: "teammates talking to each other" }); }); await h.service.start(); - h.adapter.emit(msg({ text: "Ready for QA: the safari fix", ts: "80.0", principalId: "U_PEDRO" })); - h.adapter.emit(msg({ text: "awesome work, I left a nit", ts: "80.1", threadRootTs: "80.0" })); + h.adapter.emit(msg({ text: "Ready for QA: the safari fix", ts: "80.0", principalId: "U_PEDRO", principalName: "pedro" })); + h.adapter.emit(msg({ text: "awesome work, I left a nit", ts: "80.1", threadRootTs: "80.0", principalName: "noah" })); await h.service.idle(); // pass 1 judges these with no earlier tail expect(h.earSessions()[0]!.prompts[0]).not.toContain("already heard"); - h.adapter.emit(msg({ text: "LMK if you wanna get in on browserstack", ts: "80.2", threadRootTs: "80.0" })); + h.adapter.emit(msg({ text: "LMK if you wanna get in on browserstack", ts: "80.2", threadRootTs: "80.0", principalName: "noah" })); await h.service.idle(); // pass 2's batch is one line — the thread rides along const prompt = h.earSessions().at(-1)!.prompts[0]!; expect(prompt).toContain("earlier in <#C1> thread=80.0 (already heard"); - expect(prompt).toContain("<@U_PEDRO>: Ready for QA: the safari fix"); - expect(prompt).toContain("<@U1>: awesome work, I left a nit"); + // ids arrive named (adapter roster, 0.5.0) — the ear sees people, not bare mentions + expect(prompt).toContain("<@U_PEDRO> (pedro): Ready for QA: the safari fix"); + expect(prompt).toContain("<@U1> (noah): awesome work, I left a nit"); + expect(prompt).toContain("<@U1> (noah): LMK if you wanna get in on browserstack"); // the batch line itself await h.service.stop(); });