Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/adapter/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
}
Expand Down
14 changes: 9 additions & 5 deletions src/ledger/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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 } : {}),
};
Expand All @@ -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 {
Expand Down
11 changes: 9 additions & 2 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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");
Expand Down
12 changes: 7 additions & 5 deletions test/ear.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
Loading