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
15 changes: 7 additions & 8 deletions src/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@ import { codexThread, maybeRotateGateway, type Tools } from "@bevyl-ai/agent-too
import { inject, singleton } from "tsyringe";
import type { Task } from "./ledger/schema";
import { log } from "./log";
import { POLICY, type Policy } from "./policy";
import { POLICY, type Policy, type Tier } from "./policy";
import { Soul } from "./soul";
import { earTools, residentTools, workerTools } from "./tools";
import { Workspaces, type Role } from "./workspaces";

type Tier = Policy["models"]["low"];
type Thread = Awaited<ReturnType<typeof codexThread>>["thread"];

@singleton()
Expand All @@ -19,11 +18,11 @@ export class Codex {
) {}

respond(prompt: string): Promise<void> {
return this.once("resident", residentTools, {}, this.policy.turns.timeout_ms, prompt);
return this.once("resident", residentTools, prompt);
}

judge(prompt: string): Promise<void> {
return this.once("ear", earTools, this.policy.models.low, this.policy.turns.timeout_ms, prompt);
triage(prompt: string): Promise<void> {
return this.once("ear", earTools, prompt, this.policy.models.low);
}

async runWorker(taskId: string, tier: Task["tier"], next: () => string | null): Promise<void> {
Expand All @@ -37,16 +36,16 @@ export class Codex {
}
}

private async once(role: Role, tools: Tools, tier: Tier, timeoutMs: number, prompt: string) {
private async once(role: Role, tools: Tools, prompt: string, tier?: Tier) {
const { thread, close } = await this.thread(role, tools, tier);
try {
await this.turn(thread, role, prompt, timeoutMs);
await this.turn(thread, role, prompt, this.policy.turns.timeout_ms);
} finally {
close();
}
}

private thread(role: Role, tools: Tools, tier: Tier) {
private thread(role: Role, tools: Tools, tier: Tier = {}) {
this.soul.refresh();
return codexThread({
tools,
Expand Down
19 changes: 6 additions & 13 deletions src/ledger-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, asc, count, eq, like, lte, min, or, sql } from "drizzle-orm";
import { and, asc, count, eq, like, lte, min, not, sql, type SQL } from "drizzle-orm";
import { drizzle, type BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { inject, singleton, type InjectionToken } from "tsyringe";
Expand All @@ -18,7 +18,7 @@ export function openDb(path: string): Db {
return db;
}

export const WANTED = or(eq(conversations.direct, true), eq(conversations.woken, true));
export const WANTED = sql`(${conversations.direct} OR ${conversations.woken})`;

export function thread(
table: typeof conversations | typeof mutedThreads,
Expand Down Expand Up @@ -138,7 +138,7 @@ ${text}`,
}

rendered(convos: Conversation[], settled: Task[]): void {
for (const convo of convos) this.settle(convo);
for (const convo of convos) this.caughtUp(convo);
for (const task of settled)
this.db
.update(tasks)
Expand Down Expand Up @@ -211,18 +211,11 @@ ${text}`,
}

held(convos: Conversation[]): void {
for (const convo of convos) if (!this.wanted(convo)) this.settle(convo);
for (const convo of convos) this.caughtUp(convo, not(WANTED));
}

private wanted(convo: Conversation): boolean {
const row = this.db.query.conversations
.findFirst({ where: and(thread(conversations, convo.channel, convo.threadTs), WANTED) })
.sync();
return row !== undefined;
}

private settle(convo: Conversation): void {
const where = thread(conversations, convo.channel, convo.threadTs);
private caughtUp(convo: Conversation, only?: SQL): void {
const where = and(thread(conversations, convo.channel, convo.threadTs), only);
const gone = this.db
.delete(conversations)
.where(and(where, eq(conversations.last, convo.last)))
Expand Down
2 changes: 0 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@ import { container } from "tsyringe";
import { watchFile } from "node:fs";
import { SocketModeClient } from "@slack/socket-mode";
import type { MessageEvent } from "@slack/types";
import { Roster } from "./roster";
import { Scheduler } from "./scheduler";
import { log } from "./log";
import { POLICY, POLICY_PATH, loadPolicy } from "./policy";

await container.resolve(Roster).load();
const scheduler = container.resolve(Scheduler);
log.info("service started");

Expand Down
1 change: 1 addition & 0 deletions src/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const PolicySchema = z.object({
});

export type Policy = z.infer<typeof PolicySchema>;
export type Tier = z.infer<typeof ModelTier>;
export const POLICY: InjectionToken<Policy> = Symbol("policy");
export const POLICY_PATH: InjectionToken<string> = Symbol("policyPath");

Expand Down
8 changes: 4 additions & 4 deletions src/prompt-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export class PromptRenderer {
return parts.join("\n\n");
}

async noise(convos: Conversation[]): Promise<string> {
async overheard(convos: Conversation[]): Promise<string> {
return LEGEND + (await this.batch(convos));
}

Expand Down Expand Up @@ -95,11 +95,11 @@ export class PromptRenderer {
const saved = await Promise.all((line.files ?? []).map((file) => this.attachments.save(file)));
const files = saved.length > 0 ? ` [attached: ${saved.join(", ")}]` : "";
const text = (line.text ?? "").slice(0, limit);
return ` [${channel} ${line.ts}] ${this.speaker(line.user ?? line.bot_id)}: ${text}${files}`;
return ` [${channel} ${line.ts}] ${await this.speaker(line.user ?? line.bot_id)}: ${text}${files}`;
}

private speaker(user: string | undefined): string {
const name = user ? this.roster.nameOf(user) : null;
private async speaker(user: string | undefined): Promise<string> {
const name = user ? await this.roster.nameOf(user) : null;
return `<@${user ?? "?"}>${name ? ` (${name})` : ""}`;
}
}
10 changes: 7 additions & 3 deletions src/roster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ import { WebClient, type UsersListResponse } from "@slack/web-api";
@singleton()
export class Roster {
private readonly names = new Map<string, string>();
private readonly loaded: Promise<void>;

constructor(private readonly web: WebClient) {}
constructor(private readonly web: WebClient) {
this.loaded = this.load();
}

nameOf(principalId: string): string | null {
async nameOf(principalId: string): Promise<string | null> {
await this.loaded;
return this.names.get(principalId) ?? null;
}

async load(): Promise<void> {
private async load(): Promise<void> {
for await (const page of this.web.paginate("users.list", { limit: 200 })) {
for (const member of (page as UsersListResponse).members ?? []) {
const name = [member.profile?.display_name, member.profile?.real_name, member.name].find(
Expand Down
38 changes: 11 additions & 27 deletions src/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,8 @@ const HEARD_SUBTYPES = new Set<string | undefined>([
])
@singleton()
export class Scheduler {
private responding: Promise<void> | null = null;
private respondAgain = false;
private readonly noise = new Debounced(() => this.listenToNoise());
private readonly overheard = new Debounced(() => this.triage());
private readonly replies = new Debounced(() => this.respond());

constructor(
@inject(DB) private readonly db: Db,
Expand All @@ -65,7 +64,7 @@ export class Scheduler {
) {
this.tick();
this.beat();
this.noise.schedule(0);
this.overheard.schedule(0);
}

heard(event: MessageEvent): void {
Expand All @@ -78,21 +77,7 @@ export class Scheduler {
const threadTs = message.thread_ts ?? event.ts;
if (!direct && this.ledger.muted(event.channel, threadTs)) return;
this.ledger.heard(event.channel, threadTs, event.ts, direct);
this.noise.schedule(direct ? 0 : this.policy.ear_debounce_ms);
}

private respondSoon(): void {
if (this.responding) {
this.respondAgain = true;
return;
}
this.responding = this.respond().finally(() => {
this.responding = null;
if (this.respondAgain) {
this.respondAgain = false;
this.respondSoon();
}
});
this.overheard.schedule(direct ? 0 : this.policy.ear_debounce_ms);
}

private async respond(): Promise<void> {
Expand All @@ -115,25 +100,24 @@ export class Scheduler {
if (convos.length === 0 && settled.length === 0) return;
const prompt = await this.prompts.response(convos, settled);
const direct = convos.filter((convo) => convo.direct);
for (const convo of direct) this.voice.open(convo);
this.ledger.rendered(convos, settled);
this.voice.begin();
this.voice.begin(direct);
await this.codex.respond(prompt).finally(() => {
this.voice.close(direct);
});
this.tick();
if (this.ledger.wantsResponse()) this.respondSoon();
if (this.ledger.wantsResponse()) this.replies.schedule(0);
}

private async listenToNoise(): Promise<void> {
private async triage(): Promise<void> {
const unjudged = this.db.query.conversations
.findMany({ where: and(eq(conversations.direct, false), eq(conversations.woken, false)) })
.sync();
if (unjudged.length > 0) {
await this.codex.judge(await this.prompts.noise(unjudged));
await this.codex.triage(await this.prompts.overheard(unjudged));
this.ledger.held(unjudged);
}
if (this.ledger.wantsResponse()) this.respondSoon();
if (this.ledger.wantsResponse()) this.replies.schedule(0);
}

private async runWorker(taskId: string): Promise<void> {
Expand Down Expand Up @@ -164,7 +148,7 @@ export class Scheduler {
outcome: after?.outcome,
turns,
});
if (after?.status === "done" || after?.waitingOn === "human") this.respondSoon();
if (after?.status === "done" || after?.waitingOn === "human") this.replies.schedule(0);
this.tick();
}

Expand All @@ -176,7 +160,7 @@ export class Scheduler {
}

private tick(): void {
if (this.ledger.wakeDueTasks()) this.respondSoon();
if (this.ledger.wakeDueTasks()) this.replies.schedule(0);
for (const taskId of this.ledger.dispatchRunnable(this.policy.executions.max_concurrent))
void this.runWorker(taskId);
}
Expand Down
35 changes: 14 additions & 21 deletions src/soul.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { inject, singleton } from "tsyringe";
import { log } from "./log";
import { POLICY, type Policy } from "./policy";
import { BOT_USER_ID } from "./tokens";
import ear from "./soul/ear.md" with { type: "text" };
Expand All @@ -26,25 +25,19 @@ export class Soul {
) {}

refresh(): void {
try {
const memoryPath = join(this.workspaces.resident, "MEMORY.md");
const holes = {
botUserId: this.botUserId,
persona: orElse(this.policy.persona, "(none)"),
memory: orElse(existsSync(memoryPath) ? readFileSync(memoryPath, "utf8") : "", "(empty)"),
venues: orElse(
Object.entries(this.policy.venue_instructions)
.map(([venueId, instruction]) => `- <#${venueId}>: ${instruction}`)
.join("\n"),
"(none)",
),
};
writeFileSync(join(this.workspaces.resident, "AGENTS.md"), fill(resident, holes));
writeFileSync(join(this.workspaces.ear, "AGENTS.md"), fill(ear, holes));
} catch (error) {
log.warn("could not write soul (AGENTS.md) — using codex default voice", {
error: String(error),
});
}
const memoryPath = join(this.workspaces.resident, "MEMORY.md");
const holes = {
botUserId: this.botUserId,
persona: orElse(this.policy.persona, "(none)"),
memory: orElse(existsSync(memoryPath) ? readFileSync(memoryPath, "utf8") : "", "(empty)"),
venues: orElse(
Object.entries(this.policy.venue_instructions)
.map(([venueId, instruction]) => `- <#${venueId}>: ${instruction}`)
.join("\n"),
"(none)",
),
};
writeFileSync(join(this.workspaces.resident, "AGENTS.md"), fill(resident, holes));
writeFileSync(join(this.workspaces.ear, "AGENTS.md"), fill(ear, holes));
}
}
Loading
Loading