/** * Session Handoff Service * * Generates a structured session summary on shutdown and pushes it to agents * on restart. Replaces the old Slack-only pushRecentSlackHistory approach with * a channel-agnostic summary that covers Slack, Google Chat, and Chat UI. * * Summary is stored at ~/.crewly/session-summaries/latest.md and is limited * to 50 lines for prompt-budget friendliness. * * @module services/session/session-handoff.service */ import type { RuntimeType } from '../../constants.js'; /** * Information about an active conversation thread. */ export interface ActiveThread { /** Channel type: 'slack', 'gchat', or 'chat-ui' */ channelType: 'slack' | 'gchat' | 'chat-ui'; /** Channel or space identifier */ channelId: string; /** Absolute path to the thread file */ filePath: string; /** ISO timestamp of last modification */ lastActiveAt: string; /** Last few message summaries */ recentMessages: string[]; } /** * Information about an active team member (agent). */ export interface ActiveAgentInfo { /** Agent session name */ sessionName: string; /** Agent role */ role: string; /** Current working status */ workingStatus: string; /** Current task description (if any) */ currentTask?: string; } /** * Information about a pending (non-completed) task. Sourced from the V3 * task-pool (`pool.json`) — see {@link SessionHandoffService.scanPendingTasks}. * * Historically this was scraped from `.crewly/tasks/delegated/*.md`, but * that path was retired with the V3↔.md bridge deprecation * (`specs/2026-05-06-projecttask-md-deprecation.md`). The shape is preserved * for downstream callers; `filePath` now points at the task-pool JSON file * (a stable per-process location) rather than a per-task .md file. */ export interface PendingTaskInfo { /** Task title (WorkItem.title) */ title: string; /** Agent session name the task is assigned to (WorkItem.target) */ assignedTo: string; /** Current WorkItem status — passed through verbatim (e.g. 'queued', 'running') */ status: string; /** Priority if available; empty string when WorkItem doesn't carry one */ priority: string; /** Stable identifier for the task — currently the WorkItem id */ filePath: string; } /** * Full session handoff summary data. */ export interface HandoffSummary { /** ISO timestamp of summary generation */ generatedAt: string; /** Active conversation threads across all channels */ activeThreads: ActiveThread[]; /** Active agents and their status */ activeAgents: ActiveAgentInfo[]; /** Pending tasks that have not been completed */ pendingTasks: PendingTaskInfo[]; } /** * Thread info for the resume notification sent after orchestrator restart. * Covers Slack, Google Chat, and Chat UI channels. */ export interface ResumeThread { /** Channel type: 'slack', 'gchat', or 'chat-ui' */ channelType: 'slack' | 'gchat' | 'chat-ui'; /** Channel or space identifier */ channelId: string; /** Thread identifier (threadTs for Slack, threadName for GChat, conversationId for Chat UI) */ threadId: string; /** Absolute path to the thread file */ filePath: string; /** ISO timestamp of last modification */ lastActiveAt: string; /** Recent message previews */ recentMessages: string[]; } /** * @deprecated Use ResumeThread instead. Kept for backward compatibility. */ export type SlackResumeThread = ResumeThread; /** * Service to send messages to agents. * Minimal interface to avoid tight coupling with AgentRegistrationService. */ export interface AgentMessageSender { sendMessageToAgent(session: string, content: string, runtimeType: RuntimeType): Promise<{ success: boolean; }>; } /** * Service to read team data. * Minimal interface to avoid tight coupling with StorageService. */ export interface TeamDataReader { getTeams(): Promise; }>>; } /** * Manages generation of session summaries on shutdown and delivery on restart. * * @example * ```typescript * const handoff = SessionHandoffService.getInstance(); * * // On shutdown: * await handoff.generateSummary(storageService); * * // On agent registration: * await handoff.pushSessionSummary(agentService, 'crewly-orc'); * ``` */ export declare class SessionHandoffService { private static instance; private readonly logger; /** * Channels whose threads are NOT the orchestrator's to resume. Slack team * channels belong to their team: pushing their recent threads into the * orchestrator's resume briefing made the orchestrator answer in the * team's channel ("[Orc] 在的…" under a message meant for Think Tank). */ private channelFilter; private constructor(); /** Clock, overridable in tests. */ now: () => number; /** * Install a predicate that marks channels to leave out of the resume * briefing (true = exclude). * * @param filter - Predicate, or null to clear */ setChannelFilter(filter: ((channelType: 'slack' | 'gchat', channelId: string) => boolean) | null): void; /** * Gets the singleton instance. * * @returns SessionHandoffService instance */ static getInstance(): SessionHandoffService; /** * Resets the singleton instance (for testing). */ static resetInstance(): void; /** * Returns the directory path for session summaries. * * @returns Absolute path to ~/.crewly/session-summaries/ */ getSummariesDir(): string; /** * Returns the path to the latest summary file. * * @returns Absolute path to ~/.crewly/session-summaries/latest.md */ getLatestSummaryPath(): string; /** * Scans a thread directory for the most recently modified .md files. * Works for both Slack (~/.crewly/slack-threads/) and GChat (~/.crewly/gchat-threads/). * * @param baseDir - Base directory to scan (e.g., ~/.crewly/slack-threads/) * @param channelType - Type of channel ('slack' or 'gchat') * @param maxThreads - Maximum number of threads to return * @returns Array of ActiveThread info sorted by recency */ scanThreadDirectory(baseDir: string, channelType: 'slack' | 'gchat', maxThreads?: number): Promise; /** * Scans Chat UI conversation storage for active conversations. * * @param chatDir - Path to chat storage directory (default: ~/.crewly/chat/) * @param maxConversations - Maximum number of conversations to return * @returns Array of ActiveThread info for Chat UI conversations */ scanChatUiConversations(chatDir?: string, maxConversations?: number): Promise; /** * Extracts recent message summaries from a markdown thread file. * Reads the last few message blocks (lines starting with **Name**). * * @param filePath - Absolute path to the thread .md file * @returns Array of recent message summary strings */ extractRecentMessages(filePath: string): Promise; /** * Extracts recent messages from a Chat UI JSON conversation file. * * @param filePath - Absolute path to the conversation .json file * @returns Array of recent message summary strings */ extractChatUiMessages(filePath: string): Promise; /** * Collects active agent info from team storage. * * @param teamReader - Service to read team data * @returns Array of active agent info */ collectActiveAgents(teamReader: TeamDataReader): Promise; /** * Returns pending (non-terminal) tasks from the V3 task-pool. Replaces the * legacy `.crewly/tasks/delegated/{open,in_progress}/*.md` filesystem scan * (retired per `specs/2026-05-06-projecttask-md-deprecation.md` — * V3 WorkItem is the sole source of truth for delegated work). * * Selection: any WorkItem in a non-terminal status (`queued`, `scheduled`, * `proposed`, `accepted`, `running`, `blocked`, `escalated`, * `done_by_worker`, `rejected`) — i.e. anything that still demands * attention on a session resume. * * Sort: running-first (workers actively engaged), then high-priority first. * * The legacy `tasksBaseDir` parameter is accepted for backwards compat * with existing test fixtures but is unused — the source is now the * task-pool singleton. * * @param _tasksBaseDir - Ignored. Kept so callers (e.g. session.controller) * don't need to be updated in lockstep. * @returns Array of PendingTaskInfo sorted, capped at {@link MAX_PENDING_TASKS} */ scanPendingTasks(_tasksBaseDir?: string): Promise; /** * Generates a full session handoff summary by scanning all thread stores * and collecting active agent status. Saves to ~/.crewly/session-summaries/latest.md. * * @param teamReader - Service to read team data for agent status * @param tasksBaseDir - Override for the tasks base directory (for testing) * @returns The generated summary data */ generateSummary(teamReader: TeamDataReader, tasksBaseDir?: string): Promise; /** * Formats a HandoffSummary into a markdown string, capped at MAX_SUMMARY_LINES. * * @param summary - The summary data * @returns Markdown string */ formatSummaryMarkdown(summary: HandoffSummary): string; /** * Pushes the latest session summary to an agent after registration. * Replaces the old pushRecentSlackHistory function. * * Reads ~/.crewly/session-summaries/latest.md and sends it as a * [SESSION_CONTEXT] system message. Only sends if the summary exists * and is non-empty. * * @param agentService - Service to send messages to agents * @param sessionName - Target agent session name */ pushSessionSummary(agentService: AgentMessageSender, sessionName: string): Promise; /** * Scans a thread directory (Slack or GChat format) for threads modified within maxAge. * Filters by file modification time and returns ResumeThread objects. * * @param baseDir - Directory to scan (e.g., ~/.crewly/slack-threads/) * @param channelType - Channel type ('slack' or 'gchat') * @param cutoffMs - Cutoff timestamp in ms (threads older than this are excluded) * @returns Array of ResumeThread for qualifying threads */ private scanRecentMdThreads; /** * Scans Chat UI conversations for those modified within maxAge. * * @param chatDir - Chat UI directory path * @param cutoffMs - Cutoff timestamp in ms * @returns Array of ResumeThread for qualifying conversations */ private scanRecentChatUiThreads; /** * Finds threads across all chat channels (Slack, GChat, Chat UI) that were * active within the specified time window. * * @param maxAge - Maximum age in ms for a thread to qualify (default: 24h) * @param dirOverrides - Optional directory overrides for testing * @returns Array of ResumeThread sorted by most recent first */ findRecentThreads(maxAge?: number, dirOverrides?: { slackDir?: string; gchatDir?: string; chatDir?: string; }): Promise; /** * @deprecated Use findRecentThreads() instead. Kept for backward compatibility. * Finds Slack threads that were active within the last 24 hours. * * @param maxAge - Maximum age in ms for a thread to qualify (default: 24h) * @param slackDirOverride - Optional directory override (for testing) * @returns Array of ResumeThread sorted by most recent first (Slack only) */ findRecentSlackThreads(maxAge?: number, slackDirOverride?: string): Promise; /** * Pushes a [CHAT_RESUME] notification to the orchestrator after registration, * containing info about recently active threads across all channels (Slack, * GChat, Chat UI) so it can proactively send a greeting message. * * @param agentService - Service to send messages to agents * @param sessionName - Target agent session name (typically the orchestrator) */ pushResumeNotification(agentService: AgentMessageSender, sessionName: string): Promise; /** * Render the `[CHAT_RESUME]` message body for a set of pending threads. * * Bounded on purpose: at most {@link RESUME_MAX_THREADS} threads (newest * first) and at most {@link RESUME_MAX_CHARS_PER_THREAD} characters per * thread block; anything beyond is summarised as a single "and K more" * line. The orchestrator re-reads this message on every restart, so its * size is paid at full input-token price each time. * * @param threads - Pending threads (any order; sorted newest-first here) * @returns The message plus how many threads were included / omitted */ static buildResumeMessage(threads: ResumeThread[]): { message: string; included: number; omitted: number; }; /** * Render one thread block for `[CHAT_RESUME]`, cut at a line boundary so * the block never exceeds `maxChars` (a truncation marker is appended when * lines were dropped; the heading line is always kept). * * @param thread - Thread to render * @param maxChars - Character budget for the block * @returns Rendered block (no trailing newline) */ private static renderResumeThread; /** * @deprecated Use pushResumeNotification() instead. */ pushSlackResumeNotification(agentService: AgentMessageSender, sessionName: string): Promise; } /** * Whether a conversation's last message is from a person and still unanswered. * * @param recentMessages - "sender: preview" lines, oldest first * @returns True when the last line is not the assistant's */ export declare function awaitsReply(recentMessages: readonly string[]): boolean; /** * Turn "WAITING FOR A REPLY" into "old" for a conversation last active more * than a day before `nowMs`, and add the answered/waiting label to headings * written by a Crewly that did not label them — treating an unlabelled one * as old unless it is recent. * * @param markdown - The summary as written at shutdown * @param nowMs - Now * @returns The summary with labels that hold now */ export declare function relabelStaleWaiting(markdown: string, nowMs: number): string; //# sourceMappingURL=session-handoff.service.d.ts.map