diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.ts index d7b820e8e5b..9da708dccf2 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.ts @@ -1,7 +1,7 @@ import path from 'node:path'; export type BriefingSourceStatus = { - source: 'calendar' | 'github' | 'linear' | 'local-news' | 'web'; + source: 'calendar' | 'github' | 'kilo-chat' | 'linear' | 'local-news' | 'web'; configured: boolean; ok: boolean; summary: string; diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.test.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.test.ts new file mode 100644 index 00000000000..df65ce9f750 --- /dev/null +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createKiloChatSummaryClient } from './chat-summary-client'; +import { buildYesterdayChatWindow } from './chat-summary-utils'; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +function mockFetch(impl: typeof fetch): typeof fetch { + return vi.fn(impl) as unknown as typeof fetch; +} + +function fetchInputUrl(input: string | Request | URL): string { + if (typeof input === 'string') return input; + if (input instanceof URL) return input.toString(); + return input.url; +} + +const CROCKFORD_BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +function ulidFromTimestamp(timestamp: number, suffix = '0000000000000000'): string { + let value = timestamp; + let encoded = ''; + for (let i = 0; i < 10; i += 1) { + encoded = CROCKFORD_BASE32[value % 32] + encoded; + value = Math.floor(value / 32); + } + return `${encoded}${suffix}`; +} + +function createConfiguredClient( + options: Parameters[0] +): ReturnType { + return createKiloChatSummaryClient({ + sandboxId: 'sandbox-1', + kiloChatBaseUrl: 'https://chat.example.com', + ...options, + }); +} + +describe('chat summary client', () => { + it('reports unconfigured when the gateway token is missing', async () => { + const client = createKiloChatSummaryClient({ token: '' }); + + expect(client.configured).toBe(false); + expect(client.reason).toBe('OPENCLAW_GATEWAY_TOKEN is not configured'); + await expect( + client.listConversationsForWindow( + buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') + ) + ).resolves.toEqual({ conversations: [], truncated: false }); + }); + + it('reports unconfigured when controller chat route prerequisites are missing', () => { + expect( + createKiloChatSummaryClient({ + token: 'token', + sandboxId: '', + kiloChatBaseUrl: 'https://chat.example.com', + }) + ).toMatchObject({ + configured: false, + reason: 'KILOCLAW_SANDBOX_ID is not configured', + }); + expect( + createKiloChatSummaryClient({ token: 'token', sandboxId: 'sandbox-1', kiloChatBaseUrl: '' }) + ).toMatchObject({ + configured: false, + reason: 'KILOCHAT_BASE_URL is not configured', + }); + }); + + it('lists active conversations and messages through the controller proxy', async () => { + const requests: Array<{ url: string; init: RequestInit | undefined }> = []; + const fetchImpl = mockFetch(async (input, init) => { + const url = fetchInputUrl(input); + requests.push({ url, init }); + if (url === 'http://controller/_kilo/kilo-chat/conversations?limit=100') { + return jsonResponse({ + conversations: [ + { + conversationId: 'conv-1', + title: 'Launch plan', + lastActivityAt: Date.parse('2026-05-18T09:00:00.000Z'), + }, + { + conversationId: 'conv-old', + title: 'Old', + lastActivityAt: Date.parse('2026-05-17T09:00:00.000Z'), + }, + ], + hasMore: true, + nextCursor: 'older-page', + }); + } + if (url === 'http://controller/_kilo/kilo-chat/conversations/conv-1/messages?limit=100') { + return jsonResponse({ + messages: [ + { id: '01JVNY65G00000000000000000', senderId: 'user:1', deleted: false }, + { id: '01JVNY7ZZ00000000000000000', senderId: 'bot:kiloclaw:sbx', deleted: false }, + ], + hasMore: false, + nextCursor: null, + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const client = createConfiguredClient({ + baseUrl: 'http://controller/', + token: 'token', + fetchImpl, + }); + const result = await client.listConversationsForWindow( + buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') + ); + + expect(result.truncated).toBe(false); + expect(result.conversations).toEqual([ + { + conversationId: 'conv-1', + lastActivityAt: Date.parse('2026-05-18T09:00:00.000Z'), + messages: [ + { id: '01JVNY65G00000000000000000', senderId: 'user:1', deleted: false }, + { id: '01JVNY7ZZ00000000000000000', senderId: 'bot:kiloclaw:sbx', deleted: false }, + ], + }, + ]); + expect(requests.map(request => request.url)).toEqual([ + 'http://controller/_kilo/kilo-chat/conversations?limit=100', + 'http://controller/_kilo/kilo-chat/conversations/conv-1/messages?limit=100', + ]); + expect(requests[0]?.init).toMatchObject({ + method: 'GET', + headers: { Authorization: 'Bearer token' }, + }); + }); + + it('inspects conversations whose latest activity is after the window', async () => { + // A thread that started yesterday and continued today reports a + // lastActivityAt of today, but still holds yesterday-window messages. + // It must be scanned or yesterday stats undercount spanning threads. + const yesterdayMessage = ulidFromTimestamp( + Date.parse('2026-05-18T22:00:00.000Z'), + '0000000000000001' + ); + const todayMessage = ulidFromTimestamp( + Date.parse('2026-05-19T08:00:00.000Z'), + '0000000000000002' + ); + const requests: string[] = []; + const fetchImpl = mockFetch(async input => { + const url = fetchInputUrl(input); + requests.push(url); + if (url === 'http://controller/_kilo/kilo-chat/conversations?limit=100') { + return jsonResponse({ + conversations: [ + { + conversationId: 'conv-spanning', + lastActivityAt: Date.parse('2026-05-19T08:00:00.000Z'), + }, + { + conversationId: 'conv-before-window', + lastActivityAt: Date.parse('2026-05-17T09:00:00.000Z'), + }, + ], + hasMore: false, + nextCursor: null, + }); + } + if ( + url === 'http://controller/_kilo/kilo-chat/conversations/conv-spanning/messages?limit=100' + ) { + return jsonResponse({ + messages: [ + { id: todayMessage, senderId: 'user:1', deleted: false }, + { id: yesterdayMessage, senderId: 'user:1', deleted: false }, + ], + hasMore: false, + nextCursor: null, + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const client = createConfiguredClient({ + baseUrl: 'http://controller', + token: 'token', + fetchImpl, + }); + const result = await client.listConversationsForWindow( + buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') + ); + + // conv-spanning is scanned even though its latest activity is today; + // conv-before-window is skipped because it cannot hold yesterday messages. + expect(result.conversations.map(conversation => conversation.conversationId)).toEqual([ + 'conv-spanning', + ]); + expect(result.conversations[0]?.messages.map(message => message.id)).toEqual([ + todayMessage, + yesterdayMessage, + ]); + expect(requests).toEqual([ + 'http://controller/_kilo/kilo-chat/conversations?limit=100', + 'http://controller/_kilo/kilo-chat/conversations/conv-spanning/messages?limit=100', + ]); + }); + + it('keeps paginating when a page is not sorted by descending activity', async () => { + const requests: string[] = []; + const fetchImpl = mockFetch(async input => { + const url = fetchInputUrl(input); + requests.push(url); + if (url === 'http://controller/_kilo/kilo-chat/conversations?limit=100') { + return jsonResponse({ + conversations: [ + { + conversationId: 'conv-old-first', + title: 'Old first', + lastActivityAt: Date.parse('2026-05-17T17:00:00.000Z'), + }, + { + conversationId: 'conv-yesterday-later', + title: 'Yesterday later', + lastActivityAt: Date.parse('2026-05-18T17:00:00.000Z'), + }, + ], + hasMore: true, + nextCursor: 'next-page', + }); + } + if (url === 'http://controller/_kilo/kilo-chat/conversations?limit=100&cursor=next-page') { + return jsonResponse({ + conversations: [ + { + conversationId: 'conv-yesterday-next-page', + title: 'Yesterday next page', + lastActivityAt: Date.parse('2026-05-18T12:00:00.000Z'), + }, + ], + hasMore: false, + nextCursor: null, + }); + } + if ( + url === + 'http://controller/_kilo/kilo-chat/conversations/conv-yesterday-later/messages?limit=100' || + url === + 'http://controller/_kilo/kilo-chat/conversations/conv-yesterday-next-page/messages?limit=100' + ) { + return jsonResponse({ + messages: [{ id: '01JVPPRVG00000000000000000', senderId: 'user:1', deleted: false }], + hasMore: false, + nextCursor: null, + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const client = createConfiguredClient({ + baseUrl: 'http://controller', + token: 'token', + fetchImpl, + }); + const result = await client.listConversationsForWindow( + buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') + ); + + expect(result.conversations.map(conversation => conversation.conversationId)).toEqual([ + 'conv-yesterday-later', + 'conv-yesterday-next-page', + ]); + expect(requests).toContain( + 'http://controller/_kilo/kilo-chat/conversations?limit=100&cursor=next-page' + ); + }); + + it('keeps paginating messages when a page is not sorted newest-first', async () => { + const inWindowEarly = ulidFromTimestamp( + Date.parse('2026-05-18T10:00:00.000Z'), + '0000000000000001' + ); + const inWindowLate = ulidFromTimestamp( + Date.parse('2026-05-18T08:00:00.000Z'), + '0000000000000002' + ); + const beforeWindowOne = ulidFromTimestamp( + Date.parse('2026-05-17T05:00:00.000Z'), + '0000000000000003' + ); + const beforeWindowTwo = ulidFromTimestamp( + Date.parse('2026-05-17T03:00:00.000Z'), + '0000000000000004' + ); + const secondPageMessage = ulidFromTimestamp( + Date.parse('2026-05-18T06:00:00.000Z'), + '0000000000000005' + ); + + const requests: string[] = []; + const fetchImpl = mockFetch(async input => { + const url = fetchInputUrl(input); + requests.push(url); + if (url === 'http://controller/_kilo/kilo-chat/conversations?limit=100') { + return jsonResponse({ + conversations: [ + { + conversationId: 'conv-1', + lastActivityAt: Date.parse('2026-05-18T10:00:00.000Z'), + }, + ], + hasMore: false, + nextCursor: null, + }); + } + if (url === 'http://controller/_kilo/kilo-chat/conversations/conv-1/messages?limit=100') { + // Out-of-order page: an in-window message sits between two + // before-window messages, and the page's last message is old. + // The legacy "last message only" check would stop here. + return jsonResponse({ + messages: [ + { id: beforeWindowOne, senderId: 'user:1', deleted: false }, + { id: inWindowEarly, senderId: 'user:1', deleted: false }, + { id: beforeWindowTwo, senderId: 'user:1', deleted: false }, + ], + hasMore: true, + nextCursor: 'page-2', + }); + } + if ( + url === + 'http://controller/_kilo/kilo-chat/conversations/conv-1/messages?limit=100&before=page-2' + ) { + return jsonResponse({ + messages: [ + { id: inWindowLate, senderId: 'user:1', deleted: false }, + { id: secondPageMessage, senderId: 'bot:kiloclaw:sbx', deleted: false }, + ], + hasMore: false, + nextCursor: null, + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const client = createConfiguredClient({ + baseUrl: 'http://controller', + token: 'token', + fetchImpl, + }); + const result = await client.listConversationsForWindow( + buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') + ); + + expect(result.truncated).toBe(false); + expect(result.conversations[0]?.messages.map(message => message.id)).toEqual([ + beforeWindowOne, + inWindowEarly, + beforeWindowTwo, + inWindowLate, + secondPageMessage, + ]); + expect(requests).toContain( + 'http://controller/_kilo/kilo-chat/conversations/conv-1/messages?limit=100&before=page-2' + ); + }); + + it('flags truncation when a conversation exceeds the message page cap', async () => { + const fetchImpl = mockFetch(async input => { + const url = fetchInputUrl(input); + if (url === 'http://controller/_kilo/kilo-chat/conversations?limit=100') { + return jsonResponse({ + conversations: [ + { + conversationId: 'conv-1', + lastActivityAt: Date.parse('2026-05-18T10:00:00.000Z'), + }, + ], + hasMore: false, + nextCursor: null, + }); + } + // Every message page stays in-window and always reports another page, + // so the scan can only end by hitting the page cap. + return jsonResponse({ + messages: [ + { + id: ulidFromTimestamp(Date.parse('2026-05-18T09:00:00.000Z')), + senderId: 'user:1', + deleted: false, + }, + ], + hasMore: true, + nextCursor: 'keep-going', + }); + }); + + const client = createConfiguredClient({ + baseUrl: 'http://controller', + token: 'token', + fetchImpl, + }); + const result = await client.listConversationsForWindow( + buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') + ); + + expect(result.truncated).toBe(true); + }); + + it('throws on non-ok controller responses', async () => { + const fetchImpl = mockFetch(async () => new Response('no route', { status: 404 })); + const client = createConfiguredClient({ + baseUrl: 'http://controller', + token: 'token', + fetchImpl, + }); + + await expect( + client.listConversationsForWindow( + buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') + ) + ).rejects.toThrow('Kilo Chat controller responded 404: no route'); + }); +}); diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.ts new file mode 100644 index 00000000000..66c0e285742 --- /dev/null +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.ts @@ -0,0 +1,296 @@ +import { + type ChatSummaryConversation, + type ChatSummaryMessage, + type ChatSummaryWindow, + ulidToTimestampMs, +} from './chat-summary-utils'; + +const DEFAULT_CONTROLLER_BASE_URL = 'http://127.0.0.1:18789'; +const DEFAULT_TIMEOUT_MS = 20_000; +const PAGE_LIMIT = 100; +const MAX_CONVERSATION_PAGES = 10; +const MAX_MESSAGE_PAGES_PER_CONVERSATION = 20; + +type FetchImpl = typeof fetch; + +type KiloChatConversationListItem = { + conversationId: string; + lastActivityAt: number | null; +}; + +type KiloChatMessage = ChatSummaryMessage; + +type ConversationsResponse = { + conversations: KiloChatConversationListItem[]; + hasMore: boolean; + nextCursor: string | null; +}; + +type MessagesResponse = { + messages: KiloChatMessage[]; + hasMore: boolean; + nextCursor: string | null; +}; + +export type KiloChatSummaryClientOptions = { + baseUrl?: string; + token?: string; + sandboxId?: string; + kiloChatBaseUrl?: string; + timeoutMs?: number; + fetchImpl?: FetchImpl; +}; + +export type KiloChatWindowResult = { + conversations: ChatSummaryConversation[]; + /** True when a page cap was hit and the result may under-count activity. */ + truncated: boolean; +}; + +export type KiloChatSummaryClient = { + configured: boolean; + reason: string; + listConversationsForWindow: (window: ChatSummaryWindow) => Promise; +}; + +function normalizeBaseUrl(input: string | undefined): string { + const raw = input?.trim() || DEFAULT_CONTROLLER_BASE_URL; + return raw.endsWith('/') ? raw.slice(0, -1) : raw; +} + +function asObject(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function parseConversationsResponse(value: unknown): ConversationsResponse { + const obj = asObject(value); + const conversationsRaw = Array.isArray(obj.conversations) ? obj.conversations : []; + const conversations = conversationsRaw.flatMap(item => { + const row = asObject(item); + return typeof row.conversationId === 'string' + ? [ + { + conversationId: row.conversationId, + lastActivityAt: typeof row.lastActivityAt === 'number' ? row.lastActivityAt : null, + }, + ] + : []; + }); + return { + conversations, + hasMore: obj.hasMore === true, + nextCursor: typeof obj.nextCursor === 'string' ? obj.nextCursor : null, + }; +} + +function parseMessagesResponse(value: unknown): MessagesResponse { + const obj = asObject(value); + const messagesRaw = Array.isArray(obj.messages) ? obj.messages : []; + const messages = messagesRaw.flatMap(item => { + const row = asObject(item); + return typeof row.id === 'string' && typeof row.senderId === 'string' + ? [ + { + id: row.id, + senderId: row.senderId, + deleted: row.deleted === true, + }, + ] + : []; + }); + return { + messages, + hasMore: obj.hasMore === true, + nextCursor: typeof obj.nextCursor === 'string' ? obj.nextCursor : null, + }; +} + +async function fetchJson( + fetchImpl: FetchImpl, + url: string, + token: string, + timeoutMs: number +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(url, { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error( + `Kilo Chat controller responded ${response.status}: ${await response.text()}` + ); + } + return await response.json(); + } finally { + clearTimeout(timer); + } +} + +/** + * A conversation is worth scanning when its most recent activity is at or + * after the window start. The upper bound is deliberately omitted: a thread + * that continued past the window (for example one spanning midnight) still + * holds in-window messages, and `summarizeChatActivity` filters per message + * by timestamp. Conversations last active before the window start cannot + * contribute any in-window messages, so they are skipped. + */ +function shouldInspectConversation( + conversation: KiloChatConversationListItem, + window: ChatSummaryWindow +): boolean { + return conversation.lastActivityAt !== null && conversation.lastActivityAt >= window.startMs; +} + +function shouldStopConversationScan( + conversations: KiloChatConversationListItem[], + window: ChatSummaryWindow +): boolean { + let previousActivityAt: number | null = null; + for (const conversation of conversations) { + if (conversation.lastActivityAt === null) continue; + if (previousActivityAt !== null && conversation.lastActivityAt > previousActivityAt) { + return false; + } + previousActivityAt = conversation.lastActivityAt; + } + + return conversations.some( + conversation => + conversation.lastActivityAt !== null && conversation.lastActivityAt < window.startMs + ); +} + +/** + * Mirrors `shouldStopConversationScan` for message pages. Only stop paging + * when the page is genuinely sorted newest-first AND reaches past the window + * start. If a page is not strictly descending we cannot trust the early stop, + * so we keep paginating rather than silently under-counting messages. + */ +function shouldStopMessageScan(messages: ChatSummaryMessage[], window: ChatSummaryWindow): boolean { + let previousTimestamp: number | null = null; + let reachedBeforeWindow = false; + for (const message of messages) { + const timestamp = ulidToTimestampMs(message.id); + if (timestamp === null) continue; + if (previousTimestamp !== null && timestamp > previousTimestamp) { + return false; + } + previousTimestamp = timestamp; + if (timestamp < window.startMs) reachedBeforeWindow = true; + } + return reachedBeforeWindow; +} + +export function createKiloChatSummaryClient( + options: KiloChatSummaryClientOptions = {} +): KiloChatSummaryClient { + const token = options.token ?? process.env.OPENCLAW_GATEWAY_TOKEN; + if (!token) { + return { + configured: false, + reason: 'OPENCLAW_GATEWAY_TOKEN is not configured', + listConversationsForWindow: async () => ({ conversations: [], truncated: false }), + }; + } + + const sandboxId = options.sandboxId ?? process.env.KILOCLAW_SANDBOX_ID; + if (!sandboxId) { + return { + configured: false, + reason: 'KILOCLAW_SANDBOX_ID is not configured', + listConversationsForWindow: async () => ({ conversations: [], truncated: false }), + }; + } + + const kiloChatBaseUrl = options.kiloChatBaseUrl ?? process.env.KILOCHAT_BASE_URL; + if (!kiloChatBaseUrl) { + return { + configured: false, + reason: 'KILOCHAT_BASE_URL is not configured', + listConversationsForWindow: async () => ({ conversations: [], truncated: false }), + }; + } + + const gatewayToken = token; + const fetchImpl = options.fetchImpl ?? fetch; + const baseUrl = normalizeBaseUrl(options.baseUrl ?? process.env.KILOCLAW_CONTROLLER_URL); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + async function listMessagesForConversation( + conversation: KiloChatConversationListItem, + window: ChatSummaryWindow + ): Promise<{ messages: ChatSummaryMessage[]; truncated: boolean }> { + const messages: ChatSummaryMessage[] = []; + let cursor: string | null = null; + for (let page = 0; page < MAX_MESSAGE_PAGES_PER_CONVERSATION; page += 1) { + const qs = new URLSearchParams({ limit: String(PAGE_LIMIT) }); + if (cursor) qs.set('before', cursor); + const payload = await fetchJson( + fetchImpl, + `${baseUrl}/_kilo/kilo-chat/conversations/${encodeURIComponent( + conversation.conversationId + )}/messages?${qs}`, + gatewayToken, + timeoutMs + ); + const parsed = parseMessagesResponse(payload); + messages.push(...parsed.messages); + if (!parsed.hasMore || !parsed.nextCursor || parsed.messages.length === 0) { + return { messages, truncated: false }; + } + if (shouldStopMessageScan(parsed.messages, window)) { + return { messages, truncated: false }; + } + cursor = parsed.nextCursor; + } + // Fell out of the loop because the page cap was hit; older messages remain. + return { messages, truncated: true }; + } + + async function listConversationsForWindow( + window: ChatSummaryWindow + ): Promise { + const conversations: ChatSummaryConversation[] = []; + let cursor: string | null = null; + let truncated = false; + for (let page = 0; page < MAX_CONVERSATION_PAGES; page += 1) { + const qs = new URLSearchParams({ limit: String(PAGE_LIMIT) }); + if (cursor) qs.set('cursor', cursor); + const payload = await fetchJson( + fetchImpl, + `${baseUrl}/_kilo/kilo-chat/conversations?${qs}`, + gatewayToken, + timeoutMs + ); + const parsed = parseConversationsResponse(payload); + for (const conversation of parsed.conversations) { + if (!shouldInspectConversation(conversation, window)) continue; + const result = await listMessagesForConversation(conversation, window); + if (result.truncated) truncated = true; + conversations.push({ ...conversation, messages: result.messages }); + } + if ( + !parsed.hasMore || + !parsed.nextCursor || + shouldStopConversationScan(parsed.conversations, window) + ) { + return { conversations, truncated }; + } + cursor = parsed.nextCursor; + } + // Fell out of the loop because the page cap was hit; older conversations remain. + return { conversations, truncated: true }; + } + + return { + configured: true, + reason: 'Kilo Chat controller is configured', + listConversationsForWindow, + }; +} diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-utils.test.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-utils.test.ts new file mode 100644 index 00000000000..aa80243824a --- /dev/null +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-utils.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { + buildChatSummarySectionLines, + buildChatSummaryStatus, + buildTodaySoFarChatWindow, + buildYesterdayChatWindow, + summarizeChatActivity, + ulidToTimestampMs, + type ChatSummaryConversation, +} from './chat-summary-utils'; + +const CROCKFORD_BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +function ulidFromTimestamp(timestamp: number, suffix = '0000000000000000'): string { + let value = timestamp; + let encoded = ''; + for (let i = 0; i < 10; i += 1) { + encoded = CROCKFORD_BASE32[value % 32] + encoded; + value = Math.floor(value / 32); + } + return `${encoded}${suffix}`; +} + +describe('chat summary utils', () => { + it('decodes the timestamp embedded in a ULID', () => { + const timestamp = Date.parse('2026-05-18T12:34:56.789Z'); + expect(ulidToTimestampMs(ulidFromTimestamp(timestamp))).toBe(timestamp); + expect(ulidToTimestampMs('not-a-ulid')).toBeNull(); + }); + + it('builds yesterday window in the user timezone', () => { + const window = buildYesterdayChatWindow( + new Date('2026-05-19T14:00:00.000Z'), + 'America/Los_Angeles' + ); + + expect(window.dateKey).toBe('2026-05-18'); + expect(new Date(window.startMs).toISOString()).toBe('2026-05-18T07:00:00.000Z'); + expect(new Date(window.endMs).toISOString()).toBe('2026-05-19T07:00:00.000Z'); + }); + + it('builds a DST transition day window using local midnight boundaries', () => { + const window = buildYesterdayChatWindow( + new Date('2026-04-06T00:00:00.000Z'), + 'Pacific/Auckland' + ); + + expect(window.dateKey).toBe('2026-04-05'); + expect(new Date(window.startMs).toISOString()).toBe('2026-04-04T11:00:00.000Z'); + expect(new Date(window.endMs).toISOString()).toBe('2026-04-05T12:00:00.000Z'); + }); + + it('builds today-so-far window in the user timezone', () => { + const now = new Date('2026-05-19T14:00:00.000Z'); + const window = buildTodaySoFarChatWindow(now, 'America/Los_Angeles'); + + expect(window.dateKey).toBe('2026-05-19'); + expect(new Date(window.startMs).toISOString()).toBe('2026-05-19T07:00:00.000Z'); + expect(new Date(window.endMs).toISOString()).toBe(now.toISOString()); + }); + + it('summarizes yesterday messages and ignores messages outside the window', () => { + const window = buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC'); + const conversations: ChatSummaryConversation[] = [ + { + conversationId: 'conv-1', + lastActivityAt: Date.parse('2026-05-18T23:00:00.000Z'), + messages: [ + { + id: ulidFromTimestamp(Date.parse('2026-05-18T09:00:00.000Z'), '0000000000000001'), + senderId: 'user:1', + deleted: false, + }, + { + id: ulidFromTimestamp(Date.parse('2026-05-18T09:01:00.000Z'), '0000000000000002'), + senderId: 'bot:kiloclaw:sbx', + deleted: false, + }, + { + id: ulidFromTimestamp(Date.parse('2026-05-18T23:00:00.000Z'), '0000000000000003'), + senderId: 'user:1', + deleted: true, + }, + { + id: ulidFromTimestamp(Date.parse('2026-05-19T12:00:00.000Z'), '0000000000000004'), + senderId: 'user:1', + deleted: false, + }, + ], + }, + { + conversationId: 'conv-2', + lastActivityAt: Date.parse('2026-05-18T20:00:00.000Z'), + messages: [ + { + id: ulidFromTimestamp(Date.parse('2026-05-18T20:00:00.000Z'), '0000000000000005'), + senderId: 'user:1', + deleted: false, + }, + ], + }, + ]; + + const stats = summarizeChatActivity(conversations, window); + + expect(stats).toEqual({ + activeConversationCount: 2, + messageCount: 4, + userMessageCount: 3, + botMessageCount: 1, + deletedMessageCount: 1, + }); + expect(buildChatSummaryStatus(stats, 'yesterday')).toBe( + '4 Kilo Chat messages across 2 conversations' + ); + expect(buildChatSummarySectionLines(stats, 'No Kilo Chat messages yesterday.')).toEqual([ + '- 4 messages across 2 conversations.', + '- 3 messages from you; 1 reply from Kilo.', + '- 1 of those messages was later deleted; its content is excluded from summaries.', + ]); + }); + + it('renders a quiet no-activity section', () => { + const stats = summarizeChatActivity( + [], + buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') + ); + + expect(buildChatSummaryStatus(stats, 'so far today')).toBe('0 Kilo Chat messages so far today'); + expect(buildChatSummarySectionLines(stats, 'No Kilo Chat messages so far today.')).toEqual([ + 'No Kilo Chat messages so far today.', + ]); + }); +}); diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-utils.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-utils.ts new file mode 100644 index 00000000000..00e6e1565b8 --- /dev/null +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-utils.ts @@ -0,0 +1,212 @@ +const DEFAULT_TIMEZONE = 'UTC'; +const ULID_TIME_LENGTH = 10; +const CROCKFORD_BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; +const BOT_SENDER_PREFIX = 'bot:'; + +export type ChatSummaryMessage = { + id: string; + senderId: string; + deleted: boolean; +}; + +export type ChatSummaryConversation = { + conversationId: string; + lastActivityAt: number | null; + messages: ChatSummaryMessage[]; +}; + +export type ChatSummaryWindow = { + startMs: number; + endMs: number; + dateKey: string; +}; + +export type ChatSummaryStats = { + activeConversationCount: number; + messageCount: number; + userMessageCount: number; + botMessageCount: number; + deletedMessageCount: number; +}; + +function readDatePart(parts: Intl.DateTimeFormatPart[], type: 'year' | 'month' | 'day'): string { + const value = parts.find(part => part.type === type)?.value; + if (!value) throw new Error(`Unable to format ${type}`); + return value; +} + +function dateKeyInZone(date: Date, timezone: string): string { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: timezone || DEFAULT_TIMEZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date); + return `${readDatePart(parts, 'year')}-${readDatePart(parts, 'month')}-${readDatePart(parts, 'day')}`; +} + +function addDays(dateKey: string, days: number): string { + const [year, month, day] = dateKey.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + date.setUTCDate(date.getUTCDate() + days); + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String( + date.getUTCDate() + ).padStart(2, '0')}`; +} + +function utcMillisForWallTime(dateKey: string, time: string, timezone: string): number { + const tz = timezone || DEFAULT_TIMEZONE; + const [year, month, day] = dateKey.split('-').map(Number); + const [hour, minute, second] = time.split(':').map(Number); + const targetWallMs = Date.UTC(year, month - 1, day, hour, minute, second); + let utcMs = targetWallMs; + + // Iterate from a UTC guess until formatting that instant in the target zone + // produces the requested local wall time. This keeps midnight boundaries + // correct on days where the offset changes between local and UTC midnight. + for (let attempt = 0; attempt < 4; attempt += 1) { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).formatToParts(new Date(utcMs)); + const renderedWallMs = Date.UTC( + Number(readDatePart(parts, 'year')), + Number(readDatePart(parts, 'month')) - 1, + Number(readDatePart(parts, 'day')), + Number(parts.find(part => part.type === 'hour')?.value ?? '0'), + Number(parts.find(part => part.type === 'minute')?.value ?? '0'), + Number(parts.find(part => part.type === 'second')?.value ?? '0') + ); + const delta = targetWallMs - renderedWallMs; + if (delta === 0) return utcMs; + utcMs += delta; + } + + return utcMs; +} + +export function buildYesterdayChatWindow(now: Date, timezone: string): ChatSummaryWindow { + const tz = timezone || DEFAULT_TIMEZONE; + const todayKey = dateKeyInZone(now, tz); + const yesterdayKey = addDays(todayKey, -1); + return { + startMs: utcMillisForWallTime(yesterdayKey, '00:00:00', tz), + endMs: utcMillisForWallTime(todayKey, '00:00:00', tz), + dateKey: yesterdayKey, + }; +} + +export function buildTodaySoFarChatWindow(now: Date, timezone: string): ChatSummaryWindow { + const tz = timezone || DEFAULT_TIMEZONE; + const todayKey = dateKeyInZone(now, tz); + return { + startMs: utcMillisForWallTime(todayKey, '00:00:00', tz), + endMs: now.getTime(), + dateKey: todayKey, + }; +} + +export function ulidToTimestampMs(ulid: string): number | null { + if (ulid.length < ULID_TIME_LENGTH) return null; + let value = 0; + for (const rawChar of ulid.slice(0, ULID_TIME_LENGTH).toUpperCase()) { + const digit = CROCKFORD_BASE32.indexOf(rawChar); + if (digit < 0) return null; + value = value * 32 + digit; + } + return Number.isSafeInteger(value) ? value : null; +} + +function isBotSender(senderId: string): boolean { + return senderId.startsWith(BOT_SENDER_PREFIX); +} + +export function summarizeChatActivity( + conversations: ChatSummaryConversation[], + window: ChatSummaryWindow +): ChatSummaryStats { + let messageCount = 0; + let userMessageCount = 0; + let botMessageCount = 0; + let deletedMessageCount = 0; + const activeConversationIds = new Set(); + + for (const conversation of conversations) { + for (const message of conversation.messages) { + const timestamp = ulidToTimestampMs(message.id); + if (timestamp === null || timestamp < window.startMs || timestamp >= window.endMs) { + continue; + } + + messageCount += 1; + if (message.deleted) deletedMessageCount += 1; + if (isBotSender(message.senderId)) { + botMessageCount += 1; + } else { + userMessageCount += 1; + } + + activeConversationIds.add(conversation.conversationId); + } + } + + return { + activeConversationCount: activeConversationIds.size, + messageCount, + userMessageCount, + botMessageCount, + deletedMessageCount, + }; +} + +function pluralize(count: number, singular: string, plural = `${singular}s`): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +export function buildChatSummarySectionLines( + stats: ChatSummaryStats, + emptyMessage: string +): string[] { + if (stats.messageCount === 0) { + return [emptyMessage]; + } + + const lines = [ + `- ${pluralize(stats.messageCount, 'message')} across ${pluralize( + stats.activeConversationCount, + 'conversation' + )}.`, + `- ${pluralize(stats.userMessageCount, 'message')} from you; ${pluralize( + stats.botMessageCount, + 'reply', + 'replies' + )} from Kilo.`, + ]; + + if (stats.deletedMessageCount > 0) { + // The deleted count overlaps the user/bot totals above; call that out so + // the breakdown does not read as additional messages. + const deleted = stats.deletedMessageCount; + lines.push( + deleted === 1 + ? '- 1 of those messages was later deleted; its content is excluded from summaries.' + : `- ${deleted} of those messages were later deleted; their content is excluded from summaries.` + ); + } + + return lines; +} + +export function buildChatSummaryStatus(stats: ChatSummaryStats, periodLabel: string): string { + if (stats.messageCount === 0) return `0 Kilo Chat messages ${periodLabel}`; + return `${pluralize(stats.messageCount, 'Kilo Chat message')} across ${pluralize( + stats.activeConversationCount, + 'conversation' + )}`; +} diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts index aeb9bf5a938..c6991a88f48 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts @@ -162,6 +162,10 @@ async function createHarness(options?: { // same reason — the host env must not leak in. vi.stubEnv('KILOCLAW_USER_LOCATION', options?.userLocationEnv?.KILOCLAW_USER_LOCATION ?? ''); vi.stubEnv('KILOCLAW_USER_TIMEZONE', options?.userLocationEnv?.KILOCLAW_USER_TIMEZONE ?? ''); + // Kilo Chat summary reads through the local controller only when the + // gateway token exists. Keep host env from making lifecycle tests hit a + // real controller unless a test explicitly opts in later. + vi.stubEnv('OPENCLAW_GATEWAY_TOKEN', ''); const { default: morningBriefingPlugin } = await import('./index'); const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), 'morning-briefing-')); diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts index 756ef5a5698..f54cd207975 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts @@ -1,4 +1,5 @@ import fs from 'node:fs/promises'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import path from 'node:path'; import { Type } from '@sinclair/typebox'; import type { OpenClawConfig, OpenClawPluginApi } from 'openclaw/plugin-sdk'; @@ -10,7 +11,12 @@ import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry'; // signature is actually broader than the SDK's typed `ListWebSearchProvidersParams`, // and passing the real api into a narrow-typed function fails. type SdkWebSearchRuntime = OpenClawPluginApi['runtime']['webSearch']; -import { buildBriefingMarkdown, offsetDateKey, resolveBriefingPath } from './briefing-utils'; +import { + buildBriefingMarkdown, + type BriefingDocumentSection, + offsetDateKey, + resolveBriefingPath, +} from './briefing-utils'; import { type BriefingDeliveryResult, deliverBriefingToConfiguredChannels, @@ -70,6 +76,14 @@ import { fetchCalendarEvents, resolveCalendarReady, } from './calendar-client'; +import { createKiloChatSummaryClient } from './chat-summary-client'; +import { + buildChatSummarySectionLines, + buildChatSummaryStatus, + buildTodaySoFarChatWindow, + buildYesterdayChatWindow, + summarizeChatActivity, +} from './chat-summary-utils'; const PLUGIN_ID = 'kiloclaw-morning-briefing'; const CRON_JOB_NAME = 'KiloClaw Morning Briefing'; @@ -139,11 +153,12 @@ type StoredStatus = { }; type SourceCollectionResult = { - source: 'calendar' | 'github' | 'linear' | 'local-news' | 'web'; + source: 'calendar' | 'github' | 'kilo-chat' | 'linear' | 'local-news' | 'web'; configured: boolean; ok: boolean; summary: string; sectionLines: string[]; + sections?: BriefingDocumentSection[]; /** * Optional per-source section title override. Most sources use a * fixed title (`GitHub`, `Linear`, `Web Search`), but `local-news` @@ -169,10 +184,17 @@ function resolvePluginConfig(raw: unknown): BriefingPluginConfig { }; } -async function readRequestBody(req: import('node:http').IncomingMessage): Promise { - const chunks: Buffer[] = []; - for await (const chunk of req) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); +async function readRequestBody( + req: IncomingMessage & AsyncIterable +): Promise { + const chunks: Uint8Array[] = []; + for await (const rawChunk of req) { + const chunk: unknown = rawChunk; + if (typeof chunk === 'string') { + chunks.push(new TextEncoder().encode(chunk)); + } else if (chunk instanceof Uint8Array) { + chunks.push(chunk); + } } const raw = Buffer.concat(chunks).toString('utf8').trim(); if (!raw) { @@ -185,11 +207,7 @@ async function readRequestBody(req: import('node:http').IncomingMessage): Promis } } -function sendJson( - res: import('node:http').ServerResponse, - statusCode: number, - body: Record -): void { +function sendJson(res: ServerResponse, statusCode: number, body: Record): void { res.statusCode = statusCode; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)); @@ -1328,6 +1346,65 @@ async function collectCalendar(now: Date, userTimezone: string): Promise { + const client = createKiloChatSummaryClient(); + if (!client.configured) { + return { + source: 'kilo-chat', + configured: false, + ok: true, + summary: client.reason, + sectionLines: [], + }; + } + + const yesterdayWindow = buildYesterdayChatWindow(now, userTimezone); + const todayWindow = buildTodaySoFarChatWindow(now, userTimezone); + try { + const [yesterday, today] = await Promise.all([ + client.listConversationsForWindow(yesterdayWindow), + client.listConversationsForWindow(todayWindow), + ]); + const yesterdayStats = summarizeChatActivity(yesterday.conversations, yesterdayWindow); + const todayStats = summarizeChatActivity(today.conversations, todayWindow); + const truncatedNote = + yesterday.truncated || today.truncated + ? ' (counts truncated; activity exceeded the scan limit)' + : ''; + return { + source: 'kilo-chat', + configured: true, + ok: true, + summary: `Yesterday: ${buildChatSummaryStatus( + yesterdayStats, + 'yesterday' + )}; today: ${buildChatSummaryStatus(todayStats, 'so far today')}${truncatedNote}`, + sectionLines: [], + sections: [ + { + title: `Yesterday in Chat (${yesterdayWindow.dateKey})`, + lines: buildChatSummarySectionLines(yesterdayStats, 'No Kilo Chat messages yesterday.'), + }, + { + title: 'So Far Today in Chat', + lines: buildChatSummarySectionLines(todayStats, 'No Kilo Chat messages so far today.'), + }, + ], + }; + } catch (error) { + return { + source: 'kilo-chat', + configured: true, + ok: false, + summary: `Kilo Chat summary failed: ${error instanceof Error ? error.message : String(error)}`, + sectionLines: [], + }; + } +} + async function generateBriefing( api: { runtime: { @@ -1397,13 +1474,15 @@ async function generateBriefing( ? configuredTimezone : DEFAULT_TIMEZONE; + const generatedAt = new Date(); const corePromises = [ - collectCalendar(new Date(), briefingTimezone), + collectCalendar(generatedAt, briefingTimezone), + collectKiloChatSummary(generatedAt, briefingTimezone), collectGithub(api), collectLinear(api), collectWebSearch(api, webSearchTopics), ]; - const [calendar, github, linear, web] = await Promise.all(corePromises); + const [calendar, kiloChat, github, linear, web] = await Promise.all(corePromises); // Local News slots between Linear and Web Search — interest-gated so // users who haven't opted in pay no search cost and see no // local-news entry in the source-status footer. @@ -1413,6 +1492,7 @@ async function generateBriefing( const sources: SourceCollectionResult[] = [ calendar, + kiloChat, github, linear, ...(localNews ? [localNews] : []), @@ -1422,7 +1502,7 @@ async function generateBriefing( if (successes.length === 0) { throw new Error( - 'No usable briefing sources are available. Configure at least one of Calendar, GitHub, Linear, Local News, or web search.' + 'No usable briefing sources are available. Configure at least one of Calendar, Kilo Chat, GitHub, Linear, Local News, or web search.' ); } @@ -1435,23 +1515,31 @@ async function generateBriefing( const DEFAULT_SECTION_TITLE: Record = { calendar: 'Calendar', github: 'GitHub', + // `kilo-chat` always supplies its own `sections`, so this entry only + // exists to satisfy the exhaustive Record type and is never rendered. + 'kilo-chat': 'Kilo Chat', linear: 'Linear', 'local-news': 'Local News', web: 'Web Search', }; const markdown = buildBriefingMarkdown({ dateKey, - generatedAt: new Date(), + generatedAt, statuses: sources.map(source => ({ source: source.source, configured: source.configured, ok: source.ok, summary: source.summary, })), - sections: sources.map(source => ({ - title: source.sectionTitle ?? DEFAULT_SECTION_TITLE[source.source], - lines: source.sectionLines, - })), + sections: sources.flatMap( + source => + source.sections ?? [ + { + title: source.sectionTitle ?? DEFAULT_SECTION_TITLE[source.source], + lines: source.sectionLines, + }, + ] + ), failures, }); @@ -1474,7 +1562,7 @@ async function generateBriefing( await patchStoredStatus(paths, { lastGeneratedDate: dateKey, - lastGeneratedAt: new Date().toISOString(), + lastGeneratedAt: generatedAt.toISOString(), lastPath: filePath, sourceSummary: sources.map(source => ({ source: source.source,