diff --git a/assistant/src/daemon/server.ts b/assistant/src/daemon/server.ts index 7dd71cdcb26..5b5315c9a00 100644 --- a/assistant/src/daemon/server.ts +++ b/assistant/src/daemon/server.ts @@ -46,6 +46,10 @@ import { syncIdentityNameToPlatform } from "../platform/sync-identity.js"; import { buildSystemPrompt } from "../prompts/system-prompt.js"; import { RateLimitProvider } from "../providers/ratelimit.js"; import { getProvider, initializeProviders } from "../providers/registry.js"; +import { + registerDefaultWakeResolver, + type WakeTarget, +} from "../runtime/agent-wake.js"; import { buildAssistantEvent } from "../runtime/assistant-event.js"; import { assistantEventHub } from "../runtime/assistant-event-hub.js"; import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js"; @@ -779,6 +783,26 @@ export class DaemonServer { return { accepted: true }; }); + // Install the default resolver for `wakeAgentForOpportunity()` so + // internal subsystems (e.g. the Meet chat-opportunity detector wired + // up in `MeetSessionManager`) can invoke it without having to build + // a `WakeTarget` adapter themselves. The adapter wraps a live + // `Conversation` fetched from the in-memory map / hydrated from the + // DB, exposing only the narrow surface the wake helper needs. + registerDefaultWakeResolver(async (conversationId) => { + try { + const conversation = + await this.getOrCreateConversation(conversationId); + return conversationToWakeTarget(conversation); + } catch (err) { + log.warn( + { err, conversationId }, + "agent-wake default resolver: failed to hydrate conversation", + ); + return null; + } + }); + // Wire the launchConversation helper to daemon-side state so // handleSurfaceAction can spawn conversations through it. registerLaunchConversationDeps({ @@ -1648,3 +1672,22 @@ function extractConversationId(msg: ServerMessage): string | undefined { } return undefined; } + +/** + * Adapt a live {@link Conversation} to the narrow {@link WakeTarget} + * surface expected by `wakeAgentForOpportunity()`. Kept here so the + * runtime-level wake helper stays decoupled from the heavyweight + * conversation class (see `registerDefaultWakeResolver` above). + */ +function conversationToWakeTarget(conversation: Conversation): WakeTarget { + return { + conversationId: conversation.conversationId, + agentLoop: conversation.agentLoop, + getMessages: () => conversation.getMessages(), + pushMessage: (msg) => { + conversation.messages.push(msg); + }, + emitToClient: (msg) => conversation.sendToClient(msg), + isProcessing: () => conversation.isProcessing(), + }; +} diff --git a/assistant/src/runtime/agent-wake.ts b/assistant/src/runtime/agent-wake.ts index 8409ed84852..55432cfe4d6 100644 --- a/assistant/src/runtime/agent-wake.ts +++ b/assistant/src/runtime/agent-wake.ts @@ -74,8 +74,9 @@ export interface WakeResult { } /** - * Dependencies injected for testing. Production callers use the defaults - * (which resolve the conversation from the daemon's registry). + * Dependencies injected for testing. Production callers can omit this + * argument entirely and rely on a process-wide default resolver registered + * at daemon startup via {@link registerDefaultWakeResolver}. */ export interface WakeDeps { /** Resolve the wake target for a conversationId. Returns `null` if not found. */ @@ -84,6 +85,49 @@ export interface WakeDeps { now?: () => number; } +// ── Process-wide default resolver ──────────────────────────────────── +// +// PR 6 shipped `wakeAgentForOpportunity` with a required `deps` argument +// carrying an explicit `resolveTarget`. PR 7 needs to call the helper +// from code paths (e.g. `MeetSessionManager.join`) that don't know how +// to build a `WakeTarget` — the adapter that wraps a live `Conversation` +// lives in the daemon, not the skill. To avoid importing daemon code +// into `runtime/agent-wake.ts` (and the skill bundle that wires +// proactive-chat into the manager), we expose a module-level default +// resolver that the daemon installs once at startup. Callers that don't +// pass explicit `deps` fall back to it. Tests that pass explicit deps +// are unaffected — the default is never consulted when deps are +// supplied. + +let _defaultResolver: + | ((conversationId: string) => Promise) + | null = null; + +/** + * Install the process-wide default resolver. Called once at daemon + * startup (see `DaemonServer.start()`) with an adapter that looks up a + * live {@link Conversation} and wraps it as a {@link WakeTarget}. + * + * Calling this more than once replaces the prior resolver — the daemon + * startup path should call it exactly once, but tests that want to + * exercise the default path can register a mock and reset via + * {@link resetDefaultWakeResolverForTests}. + */ +export function registerDefaultWakeResolver( + resolver: (conversationId: string) => Promise, +): void { + _defaultResolver = resolver; +} + +/** + * Reset the process-wide default resolver. Test-only. + * + * @internal + */ +export function resetDefaultWakeResolverForTests(): void { + _defaultResolver = null; +} + // ── Per-conversation single-flight lock ─────────────────────────────── // // Simple promise-chain map. When a wake arrives and another run is in @@ -183,17 +227,30 @@ function inspectAssistantOutput( * * See module-level doc for semantics. Safe to call concurrently; wakes * are serialized per `conversationId`. + * + * The `deps` argument is optional in production — when omitted, the + * process-wide resolver registered by + * {@link registerDefaultWakeResolver} is used. Tests that want tight + * control over resolution continue to pass explicit deps. */ export async function wakeAgentForOpportunity( opts: WakeOptions, - deps: WakeDeps, + deps?: WakeDeps, ): Promise { const { conversationId, hint, source } = opts; - const nowFn = deps.now ?? Date.now; + const resolveTarget = deps?.resolveTarget ?? _defaultResolver; + if (!resolveTarget) { + log.warn( + { conversationId, source }, + "agent-wake: no resolver available (default resolver not registered and no deps passed); skipping", + ); + return { invoked: false, producedToolCalls: false }; + } + const nowFn = deps?.now ?? Date.now; const startedAt = nowFn(); return runSingleFlight(conversationId, async () => { - const target = await deps.resolveTarget(conversationId); + const target = await resolveTarget(conversationId); if (!target) { log.warn( { conversationId, source }, diff --git a/skills/meet-join/daemon/__tests__/session-manager.test.ts b/skills/meet-join/daemon/__tests__/session-manager.test.ts index 8998d33f4bb..130396fecf0 100644 --- a/skills/meet-join/daemon/__tests__/session-manager.test.ts +++ b/skills/meet-join/daemon/__tests__/session-manager.test.ts @@ -1,11 +1,16 @@ -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { invalidateConfigCache } from "../../../../assistant/src/config/loader.js"; import type { AssistantEvent } from "../../../../assistant/src/runtime/assistant-event.js"; import { assistantEventHub } from "../../../../assistant/src/runtime/assistant-event-hub.js"; import { DAEMON_INTERNAL_ASSISTANT_ID } from "../../../../assistant/src/runtime/assistant-scope.js"; +import type { + ChatOpportunityDecision, + ChatOpportunityDetectorStats, +} from "../chat-opportunity-detector.js"; import { meetEventDispatcher } from "../event-publisher.js"; import { __resetMeetSessionEventRouterForTests, @@ -14,6 +19,8 @@ import { import { _createMeetSessionManagerForTests, BOT_LEAVE_HTTP_TIMEOUT_MS, + type MeetChatOpportunityDetectorFactoryArgs, + type MeetChatOpportunityDetectorLike, MEET_BOT_INTERNAL_PORT, MEET_JOIN_NAME_FALLBACK, type MeetAudioIngestLike, @@ -1199,3 +1206,356 @@ describe("MeetSessionManager bridge + writer wiring", () => { await manager.leave("m-writer-fail", "cleanup"); }); }); + +// --------------------------------------------------------------------------- +// Proactive chat-opportunity detector wiring (PR 7) +// --------------------------------------------------------------------------- + +describe("MeetSessionManager proactive chat-opportunity detector wiring", () => { + /** + * Make a fake detector and the factory that produced it so tests can + * assert on construction arguments (assistantDisplayName, config, + * callDetectorLLM, onOpportunity) and lifecycle (start, dispose) without + * standing up the real regex + LLM stack. + */ + interface FakeDetector extends MeetChatOpportunityDetectorLike { + start: ReturnType; + dispose: ReturnType; + getStats: ReturnType; + /** Test helper — simulates a Tier 2 positive verdict firing the callback. */ + fireOpportunity: (hint: string) => void; + } + + function makeFakeDetectorFactory( + stats: ChatOpportunityDetectorStats = { + tier1Hits: 2, + tier2Calls: 1, + tier2PositiveCount: 1, + escalationsFired: 1, + escalationsSuppressed: 0, + }, + ): { + factory: (args: MeetChatOpportunityDetectorFactoryArgs) => FakeDetector; + lastDetector: () => FakeDetector | null; + lastArgs: () => MeetChatOpportunityDetectorFactoryArgs | null; + } { + let detector: FakeDetector | null = null; + let args: MeetChatOpportunityDetectorFactoryArgs | null = null; + return { + factory: (factoryArgs) => { + args = factoryArgs; + let capturedOnOpportunity = factoryArgs.onOpportunity; + const fake: FakeDetector = { + start: mock(() => {}), + dispose: mock(() => {}), + getStats: mock(() => ({ ...stats })), + fireOpportunity: (hint: string) => capturedOnOpportunity(hint), + }; + detector = fake; + return fake; + }, + lastDetector: () => detector, + lastArgs: () => args, + }; + } + + /** + * Writes a `config.json` to the test workspace and invalidates the + * config cache so `getConfig()` picks up the override. Paired with + * an `afterEach` that tears the file down and invalidates again — + * the rest of the file relies on schema defaults, so leaving an + * override in place would poison subsequent tests. + */ + function overrideProactiveChatConfig( + workspace: string, + enabled: boolean, + ): void { + const configPath = join(workspace, "config.json"); + writeFileSync( + configPath, + JSON.stringify( + { + services: { + meet: { + proactiveChat: { + enabled, + }, + }, + }, + }, + null, + 2, + ), + ); + invalidateConfigCache(); + } + + afterEach(() => { + // Reset any config override so other describe blocks see schema defaults. + invalidateConfigCache(); + }); + + test("join constructs detector with effectiveJoinName, proactiveChat config, and wake callback", async () => { + // VELLUM_WORKSPACE_DIR is set by test-preload to a distinct path + // from `workspaceDir`, so we point config writes at the preload + // path (which `getConfig()` reads) while the manager uses + // `workspaceDir` for its per-meeting directory staging. The two + // don't have to match — session manager reads `services.meet.*` + // via `getConfig()` (preload dir) and uses `deps.getWorkspaceDir` + // for disk layout (test-local override). + const preloadWorkspace = process.env.VELLUM_WORKSPACE_DIR!; + overrideProactiveChatConfig(preloadWorkspace, true); + + const runner = makeMockRunner(); + const audioIngestFactory = makeFakeAudioIngestFactory(); + const detectorFactory = makeFakeDetectorFactory(); + const wakeAgent = mock(async () => {}); + + const manager = _createMeetSessionManagerForTests({ + dockerRunnerFactory: () => runner, + getProviderKey: async () => "k", + getWorkspaceDir: () => workspaceDir, + botLeaveFetch: async () => {}, + audioIngestFactory: audioIngestFactory.factory, + chatOpportunityDetectorFactory: detectorFactory.factory, + wakeAgent, + resolveAssistantDisplayName: () => "Atlas", + }); + + await manager.join({ + url: "u", + meetingId: "m-proactive-on", + conversationId: "conv-pchat-1", + }); + + const args = detectorFactory.lastArgs(); + expect(args).not.toBeNull(); + // assistantDisplayName flows from the same effectiveJoinName source + // as services.meet.joinName / JOIN_NAME — critical for the detector's + // name-mention regex to match what the bot actually announces. + expect(args!.assistantDisplayName).toBe("Atlas"); + expect(args!.meetingId).toBe("m-proactive-on"); + expect(args!.conversationId).toBe("conv-pchat-1"); + expect(args!.config.enabled).toBe(true); + // detectorKeywords array is carried over unchanged (spreading to a + // fresh array — verify by shape, not identity). + expect(Array.isArray(args!.config.detectorKeywords)).toBe(true); + expect(args!.config.detectorKeywords.length).toBeGreaterThan(0); + + const detector = detectorFactory.lastDetector()!; + expect(detector.start).toHaveBeenCalledTimes(1); + + // Fire an opportunity — manager should invoke wakeAgent with the + // configured source and the hint passed through verbatim. + detector.fireOpportunity("question directed at assistant"); + // wakeAgent is called via `void this.deps.wakeAgent(...)` — allow + // the microtask to settle before asserting. + await Promise.resolve(); + + expect(wakeAgent).toHaveBeenCalledTimes(1); + const calls = wakeAgent.mock.calls as unknown as Array< + [{ conversationId: string; hint: string; source: string }] + >; + expect(calls[0]![0]).toEqual({ + conversationId: "conv-pchat-1", + hint: "question directed at assistant", + source: "meet-chat-opportunity", + }); + + await manager.leave("m-proactive-on", "cleanup"); + // Detector disposed on leave. + expect(detector.dispose).toHaveBeenCalledTimes(1); + }); + + test("proactiveChat.enabled=false skips detector construction entirely", async () => { + const preloadWorkspace = process.env.VELLUM_WORKSPACE_DIR!; + overrideProactiveChatConfig(preloadWorkspace, false); + + const runner = makeMockRunner(); + const audioIngestFactory = makeFakeAudioIngestFactory(); + const detectorFactory = makeFakeDetectorFactory(); + const wakeAgent = mock(async () => {}); + + const manager = _createMeetSessionManagerForTests({ + dockerRunnerFactory: () => runner, + getProviderKey: async () => "k", + getWorkspaceDir: () => workspaceDir, + botLeaveFetch: async () => {}, + audioIngestFactory: audioIngestFactory.factory, + chatOpportunityDetectorFactory: detectorFactory.factory, + wakeAgent, + }); + + await manager.join({ + url: "u", + meetingId: "m-proactive-off", + conversationId: "c", + }); + + // Factory was never invoked. + expect(detectorFactory.lastDetector()).toBeNull(); + expect(detectorFactory.lastArgs()).toBeNull(); + // No wakes possible when no detector exists. + expect(wakeAgent).toHaveBeenCalledTimes(0); + + await manager.leave("m-proactive-off", "cleanup"); + }); + + test("leave disposes the detector and leave still works when detector is null", async () => { + const preloadWorkspace = process.env.VELLUM_WORKSPACE_DIR!; + + // First case — detector present, dispose on leave. + overrideProactiveChatConfig(preloadWorkspace, true); + const detectorFactoryOn = makeFakeDetectorFactory(); + const managerOn = _createMeetSessionManagerForTests({ + dockerRunnerFactory: () => makeMockRunner(), + getProviderKey: async () => "k", + getWorkspaceDir: () => workspaceDir, + botLeaveFetch: async () => {}, + audioIngestFactory: makeFakeAudioIngestFactory().factory, + chatOpportunityDetectorFactory: detectorFactoryOn.factory, + wakeAgent: async () => {}, + }); + await managerOn.join({ + url: "u", + meetingId: "m-dispose-on", + conversationId: "c", + }); + await managerOn.leave("m-dispose-on", "cleanup"); + expect(detectorFactoryOn.lastDetector()!.dispose).toHaveBeenCalledTimes(1); + + // Second case — detector absent (enabled=false), leave must not throw + // on the `detector?.dispose()` / `detector?.getStats()` paths. + overrideProactiveChatConfig(preloadWorkspace, false); + const detectorFactoryOff = makeFakeDetectorFactory(); + const managerOff = _createMeetSessionManagerForTests({ + dockerRunnerFactory: () => makeMockRunner(), + getProviderKey: async () => "k", + getWorkspaceDir: () => workspaceDir, + botLeaveFetch: async () => {}, + audioIngestFactory: makeFakeAudioIngestFactory().factory, + chatOpportunityDetectorFactory: detectorFactoryOff.factory, + wakeAgent: async () => {}, + }); + await managerOff.join({ + url: "u", + meetingId: "m-dispose-off", + conversationId: "c", + }); + await managerOff.leave("m-dispose-off", "cleanup"); + // Factory never called; no detector to dispose. + expect(detectorFactoryOff.lastDetector()).toBeNull(); + }); + + test("wakeAgent rejection is swallowed so the detector callback can't throw", async () => { + const preloadWorkspace = process.env.VELLUM_WORKSPACE_DIR!; + overrideProactiveChatConfig(preloadWorkspace, true); + + const detectorFactory = makeFakeDetectorFactory(); + const wakeAgent = mock(async () => { + throw new Error("wake exploded"); + }); + + const manager = _createMeetSessionManagerForTests({ + dockerRunnerFactory: () => makeMockRunner(), + getProviderKey: async () => "k", + getWorkspaceDir: () => workspaceDir, + botLeaveFetch: async () => {}, + audioIngestFactory: makeFakeAudioIngestFactory().factory, + chatOpportunityDetectorFactory: detectorFactory.factory, + wakeAgent, + }); + + await manager.join({ + url: "u", + meetingId: "m-wake-throws", + conversationId: "c", + }); + + const detector = detectorFactory.lastDetector()!; + // Calling fireOpportunity synchronously must not raise — the manager + // wraps the async wake in `.catch()` so the detector's callback + // surface stays `void`. + expect(() => detector.fireOpportunity("x")).not.toThrow(); + // Let the rejection propagate to its handler. + await Promise.resolve(); + await Promise.resolve(); + + expect(wakeAgent).toHaveBeenCalledTimes(1); + + await manager.leave("m-wake-throws", "cleanup"); + }); + + test("leave logs a per-meeting chatOpportunity summary pulled from detector.getStats()", async () => { + const preloadWorkspace = process.env.VELLUM_WORKSPACE_DIR!; + overrideProactiveChatConfig(preloadWorkspace, true); + + const detectorFactory = makeFakeDetectorFactory({ + tier1Hits: 7, + tier2Calls: 3, + tier2PositiveCount: 2, + escalationsFired: 1, + escalationsSuppressed: 1, + }); + + const manager = _createMeetSessionManagerForTests({ + dockerRunnerFactory: () => makeMockRunner(), + getProviderKey: async () => "k", + getWorkspaceDir: () => workspaceDir, + botLeaveFetch: async () => {}, + audioIngestFactory: makeFakeAudioIngestFactory().factory, + chatOpportunityDetectorFactory: detectorFactory.factory, + wakeAgent: async () => {}, + }); + + await manager.join({ + url: "u", + meetingId: "m-summary", + conversationId: "c", + }); + await manager.leave("m-summary", "cleanup"); + + const detector = detectorFactory.lastDetector()!; + // `leave()` calls `getStats()` to materialize the summary log line + // before emitting `meet.left`. + expect(detector.getStats).toHaveBeenCalledTimes(1); + }); + + test("default detector LLM callback returns a ChatOpportunityDecision shape", async () => { + // Smoke-test that the decision shape propagates unchanged through the + // factory's `callDetectorLLM` hook. Constructing the real detector + // here would pull in the provider stack, so we just verify the + // factory receives a callable that can return the right shape. + const preloadWorkspace = process.env.VELLUM_WORKSPACE_DIR!; + overrideProactiveChatConfig(preloadWorkspace, true); + + const detectorFactory = makeFakeDetectorFactory(); + const manager = _createMeetSessionManagerForTests({ + dockerRunnerFactory: () => makeMockRunner(), + getProviderKey: async () => "k", + getWorkspaceDir: () => workspaceDir, + botLeaveFetch: async () => {}, + audioIngestFactory: makeFakeAudioIngestFactory().factory, + chatOpportunityDetectorFactory: detectorFactory.factory, + wakeAgent: async () => {}, + }); + + await manager.join({ + url: "u", + meetingId: "m-llm-shape", + conversationId: "c", + }); + + const args = detectorFactory.lastArgs()!; + expect(typeof args.callDetectorLLM).toBe("function"); + // Confirm the declared return type is `Promise` + // by exercising the type — this asserts nothing at runtime but guards + // against accidental drift in the injected callback's signature. + const _typeGuard: ( + p: string, + ) => Promise = args.callDetectorLLM; + expect(typeof _typeGuard).toBe("function"); + + await manager.leave("m-llm-shape", "cleanup"); + }); +}); diff --git a/skills/meet-join/daemon/session-manager.ts b/skills/meet-join/daemon/session-manager.ts index a7e19f1def1..e121b436ef5 100644 --- a/skills/meet-join/daemon/session-manager.ts +++ b/skills/meet-join/daemon/session-manager.ts @@ -59,11 +59,29 @@ import { join } from "node:path"; import { getConfig } from "../../../assistant/src/config/loader.js"; import { getAssistantName } from "../../../assistant/src/daemon/identity-helpers.js"; import { addMessage } from "../../../assistant/src/memory/conversation-crud.js"; +import { + createTimeout, + extractToolUse, + getConfiguredProvider, + userMessage, +} from "../../../assistant/src/providers/provider-send-message.js"; +import type { + Provider, + ToolDefinition, +} from "../../../assistant/src/providers/types.js"; +import { wakeAgentForOpportunity } from "../../../assistant/src/runtime/agent-wake.js"; import { DAEMON_INTERNAL_ASSISTANT_ID } from "../../../assistant/src/runtime/assistant-scope.js"; import { getProviderKeyAsync } from "../../../assistant/src/security/secure-keys.js"; import { getLogger } from "../../../assistant/src/util/logger.js"; import { getWorkspaceDir } from "../../../assistant/src/util/platform.js"; import { MeetAudioIngest } from "./audio-ingest.js"; +import { + type ChatOpportunityDecision, + type ChatOpportunityDetectorStats, + type ChatOpportunityLLMAsk, + MeetChatOpportunityDetector, + type ProactiveChatConfig, +} from "./chat-opportunity-detector.js"; import { MeetConsentMonitor, type MeetSessionLeaver, @@ -110,6 +128,12 @@ export const MEET_SHUTDOWN_DEADLINE_MS = 15_000; /** Default daemon HTTP port when `RUNTIME_HTTP_PORT` is not set. */ const DEFAULT_DAEMON_PORT = 7821; +/** Tier 2 chat-opportunity LLM timeout — bounds the proactive-chat path. */ +export const CHAT_OPPORTUNITY_LLM_TIMEOUT_MS = 5_000; + +/** Tier 2 chat-opportunity LLM max tokens for the structured response. */ +export const CHAT_OPPORTUNITY_LLM_MAX_TOKENS = 256; + /** * Fallback display name forwarded to the bot container when neither * `services.meet.joinName` nor `getAssistantName()` resolve a value. The @@ -245,6 +269,15 @@ interface ActiveSession extends MeetSession { * `meta.json` is flushed before the dispatcher is unregistered. */ storageWriter: MeetStorageWriterLike; + /** + * Chat-opportunity detector — watches transcript and inbound chat for + * proactive-response opportunities and fires + * {@link wakeAgentForOpportunity} when Tier 1 + Tier 2 both confirm. + * Constructed in `join()` only when + * `services.meet.proactiveChat.enabled === true`; `null` otherwise. + * Disposed in `leave()` before the dispatcher is unregistered. + */ + chatOpportunityDetector: MeetChatOpportunityDetectorLike | null; } /** @@ -271,6 +304,20 @@ export interface MeetConsentMonitorLike { stop(): void; } +/** + * Thin interface for the chat-opportunity detector surface the session + * manager uses. Lets tests swap in a fake without needing the real LLM + * stack or dispatcher subscription. Mirrors + * {@link MeetChatOpportunityDetector} — `start` subscribes, `dispose` + * unsubscribes, `getStats` exposes the running counters that `leave()` + * emits as a per-meeting summary log line. + */ +export interface MeetChatOpportunityDetectorLike { + start(): void; + dispose(): void; + getStats(): ChatOpportunityDetectorStats; +} + /** * Thin interface for the conversation bridge surface the session manager * uses. Lets tests swap in a fake without needing the real dispatcher @@ -311,6 +358,19 @@ export interface MeetStorageWriterFactoryArgs { meetingId: string; } +/** + * Arguments passed to + * {@link MeetSessionManagerDeps.chatOpportunityDetectorFactory}. + */ +export interface MeetChatOpportunityDetectorFactoryArgs { + meetingId: string; + conversationId: string; + assistantDisplayName: string; + config: ProactiveChatConfig; + callDetectorLLM: ChatOpportunityLLMAsk; + onOpportunity: (hint: string) => void; +} + export interface MeetSessionManagerDeps { /** Factory for the Docker runner — swapped in tests. */ dockerRunnerFactory?: () => Pick< @@ -378,6 +438,32 @@ export interface MeetSessionManagerDeps { * conversation CRUD module. */ insertMessage?: InsertMessageFn; + /** + * Override the chat-opportunity-detector factory. Default constructs a + * {@link MeetChatOpportunityDetector} with a Tier 2 LLM callback that + * routes through the repo-wide provider abstraction at + * `modelIntent: "latency-optimized"`. Tests can inject a fake to + * observe start/dispose/stats without spinning up the LLM path. + * + * Only consulted when `services.meet.proactiveChat.enabled === true`. + */ + chatOpportunityDetectorFactory?: ( + args: MeetChatOpportunityDetectorFactoryArgs, + ) => MeetChatOpportunityDetectorLike; + /** + * Override the function the session manager calls to wake the agent + * loop when the detector fires an opportunity. Default routes through + * the runtime-level {@link wakeAgentForOpportunity} using the + * process-wide default resolver installed by the daemon startup. + * + * Tests can inject a spy to observe the wake payload without touching + * the real conversation registry. + */ + wakeAgent?: (opts: { + conversationId: string; + hint: string; + source: string; + }) => Promise; } class MeetSessionManagerImpl { @@ -414,6 +500,10 @@ class MeetSessionManagerImpl { resolveAssistantDisplayName: deps.resolveAssistantDisplayName ?? getAssistantName, insertMessage, + chatOpportunityDetectorFactory: + deps.chatOpportunityDetectorFactory ?? + defaultChatOpportunityDetectorFactory, + wakeAgent: deps.wakeAgent ?? defaultWakeAgent, }; // The ingress route (PR 9) looks up per-meeting tokens through this @@ -451,6 +541,11 @@ class MeetSessionManagerImpl { } catch { /* best-effort */ } + try { + session.chatOpportunityDetector?.dispose(); + } catch { + /* best-effort */ + } } this.sessions.clear(); } @@ -690,6 +785,46 @@ class MeetSessionManagerImpl { // `/meets//`. const storageWriter = this.deps.storageWriterFactory({ meetingId }); + // Chat-opportunity detector — proactively watches transcript/chat for + // moments where the assistant chiming in via meeting chat would help, + // and wakes the agent loop on positive Tier 2 verdicts. Constructed + // only when `services.meet.proactiveChat.enabled === true`; keeping + // the detector null when disabled means zero lifecycle overhead and + // no event-handler cost on the dispatcher path. + const proactiveChatConfig = meet.proactiveChat; + const chatOpportunityDetector: MeetChatOpportunityDetectorLike | null = + proactiveChatConfig.enabled + ? this.deps.chatOpportunityDetectorFactory({ + meetingId, + conversationId, + assistantDisplayName: effectiveJoinName, + config: { + enabled: proactiveChatConfig.enabled, + detectorKeywords: [...proactiveChatConfig.detectorKeywords], + tier2DebounceMs: proactiveChatConfig.tier2DebounceMs, + escalationCooldownSec: + proactiveChatConfig.escalationCooldownSec, + tier2MaxTranscriptSec: + proactiveChatConfig.tier2MaxTranscriptSec, + }, + callDetectorLLM: defaultCallDetectorLLM, + onOpportunity: (hint: string) => { + void this.deps + .wakeAgent({ + conversationId, + hint, + source: "meet-chat-opportunity", + }) + .catch((err) => { + log.warn( + { err, meetingId, conversationId }, + "MeetChatOpportunityDetector: wakeAgent rejected — dropping opportunity", + ); + }); + }, + }) + : null; + const startedAt = Date.now(); const session: ActiveSession = { meetingId, @@ -706,6 +841,7 @@ class MeetSessionManagerImpl { consentMonitor, conversationBridge, storageWriter, + chatOpportunityDetector, }; this.sessions.set(meetingId, session); @@ -767,6 +903,10 @@ class MeetSessionManagerImpl { // start the consent monitor so it has a live dispatcher to attach to. consentMonitor.start(); + // Chat-opportunity detector subscribes to the same dispatcher. Skipped + // entirely when `proactiveChat.enabled === false` (detector is null). + chatOpportunityDetector?.start(); + // Max-meeting-minutes hard cap. Using setTimeout keeps this compatible // with Bun's fake-timer harness for tests. session.timeoutHandle = setTimeout(() => { @@ -824,6 +964,18 @@ class MeetSessionManagerImpl { ); } + // Dispose the chat-opportunity detector alongside the consent monitor + // so no late transcript/chat event fires an agent wake during + // teardown. Safe when the detector is null (proactive chat disabled). + try { + session.chatOpportunityDetector?.dispose(); + } catch (err) { + log.warn( + { err, meetingId }, + "MeetChatOpportunityDetector.dispose threw during leave — continuing teardown", + ); + } + // Immediately clear state so we don't re-enter this path via the timeout // firing concurrently with a caller-initiated leave. if (session.timeoutHandle) { @@ -936,6 +1088,15 @@ class MeetSessionManagerImpl { ); } + // Per-meeting proactive-chat summary. Emitted unconditionally on + // leave when a detector was constructed, even if `enabled` was later + // flipped off at config-watcher time — the stats snapshot is cheap + // and the log line is useful telemetry for tuning the Tier 1 + Tier 2 + // gating. When the detector was never constructed the field is + // absent. + const chatStats: ChatOpportunityDetectorStats | undefined = + session.chatOpportunityDetector?.getStats(); + void publishMeetEvent( DAEMON_INTERNAL_ASSISTANT_ID, meetingId, @@ -944,7 +1105,13 @@ class MeetSessionManagerImpl { ); log.info( - { meetingId, containerId: session.containerId, reason, gracefulOk }, + { + meetingId, + containerId: session.containerId, + reason, + gracefulOk, + chatOpportunityStats: chatStats, + }, "Meet session left", ); } @@ -1093,6 +1260,11 @@ class MeetSessionManagerImpl { } catch { /* best-effort */ } + try { + lingering.chatOpportunityDetector?.dispose(); + } catch { + /* best-effort */ + } try { lingering.conversationBridge.unsubscribe(); } catch { @@ -1188,6 +1360,123 @@ function defaultConsentMonitorFactory( }); } +/** + * Default {@link MeetChatOpportunityDetector} factory. The Tier 2 LLM + * callback is injected from module scope (see + * {@link defaultCallDetectorLLM}) rather than baked into the detector + * itself so tests can swap the whole factory when they want to avoid + * the provider stack entirely. + */ +function defaultChatOpportunityDetectorFactory( + args: MeetChatOpportunityDetectorFactoryArgs, +): MeetChatOpportunityDetectorLike { + return new MeetChatOpportunityDetector({ + meetingId: args.meetingId, + assistantDisplayName: args.assistantDisplayName, + config: args.config, + callDetectorLLM: args.callDetectorLLM, + onOpportunity: args.onOpportunity, + }); +} + +/** + * Tool schema used to force structured JSON output from the Tier 2 LLM. + * Mirrors the consent-monitor's `report_objection` tool pattern — the + * same provider abstraction works for both, we just differ on the + * schema. + */ +const CHAT_OPPORTUNITY_TOOL: ToolDefinition = { + name: "report_chat_opportunity", + description: + "Report whether the AI assistant chiming in via meeting chat would be appropriate and helpful here.", + input_schema: { + type: "object" as const, + properties: { + shouldRespond: { + type: "boolean", + description: + "True if the AI assistant should post a helpful chat response now; false otherwise.", + }, + reason: { + type: "string", + description: + "Brief rationale for the decision. For positive verdicts, a one-line description of what the assistant should address; for negative verdicts, why intervention is inappropriate.", + }, + }, + required: ["shouldRespond", "reason"], + }, +}; + +/** + * Default Tier 2 chat-opportunity LLM callback. Routes through the + * repo-wide provider abstraction at + * `modelIntent: "latency-optimized"` — keeping the proactive-chat path + * on the same latency tier the consent monitor uses so both background + * loops share tuning. Times out at + * {@link CHAT_OPPORTUNITY_LLM_TIMEOUT_MS} and extracts the tool-use + * input as the structured verdict. + * + * On missing provider or malformed output we fall back to a conservative + * `shouldRespond: false` verdict — never interrupt a meeting because of + * missing infrastructure. + */ +async function defaultCallDetectorLLM( + prompt: string, +): Promise { + const provider: Provider | null = await getConfiguredProvider(); + if (!provider) { + return { shouldRespond: false, reason: "" }; + } + + const { signal, cleanup } = createTimeout(CHAT_OPPORTUNITY_LLM_TIMEOUT_MS); + try { + const response = await provider.sendMessage( + [userMessage(prompt)], + [CHAT_OPPORTUNITY_TOOL], + "You are a strict JSON classifier. Only respond via the report_chat_opportunity tool.", + { + config: { + modelIntent: "latency-optimized", + max_tokens: CHAT_OPPORTUNITY_LLM_MAX_TOKENS, + tool_choice: { + type: "tool" as const, + name: CHAT_OPPORTUNITY_TOOL.name, + }, + }, + signal, + }, + ); + const tool = extractToolUse(response); + if (!tool) return { shouldRespond: false, reason: "" }; + const input = tool.input as { shouldRespond?: unknown; reason?: unknown }; + return { + shouldRespond: input.shouldRespond === true, + reason: typeof input.reason === "string" ? input.reason : "", + }; + } finally { + cleanup(); + } +} + +/** + * Default wake-agent invocation used by the chat-opportunity detector's + * `onOpportunity` callback. Delegates to the runtime-level + * {@link wakeAgentForOpportunity}, which resolves the target + * conversation via the process-wide default resolver installed at + * daemon startup (see `server.ts`). + * + * Accepts and discards the wake result so the detector's callback + * signature stays `void`. Errors bubble to the detector's own + * `onOpportunity` error-handling path, which logs and drops. + */ +async function defaultWakeAgent(opts: { + conversationId: string; + hint: string; + source: string; +}): Promise { + await wakeAgentForOpportunity(opts); +} + /** * Substitute `{assistantName}` in a consent-message template. Safe against * empty templates and against names that happen to contain regex-magic