From 0405fbc910ca71e1b2e68a793954abbafd45b311 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Tue, 19 May 2026 15:25:55 -0700 Subject: [PATCH 1/6] feat(kiloclaw): add chat activity to morning briefing --- .../src/briefing-utils.ts | 2 +- .../src/chat-summary-client.test.ts | 115 +++++++++ .../src/chat-summary-client.ts | 236 ++++++++++++++++++ .../src/chat-summary-utils.test.ts | 119 +++++++++ .../src/chat-summary-utils.ts | 201 +++++++++++++++ .../src/index.lifecycle.test.ts | 4 + .../kiloclaw-morning-briefing/src/index.ts | 83 ++++-- 7 files changed, 744 insertions(+), 16 deletions(-) create mode 100644 services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.test.ts create mode 100644 services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.ts create mode 100644 services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-utils.test.ts create mode 100644 services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-utils.ts 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..45c37b8e2c3 --- /dev/null +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.test.ts @@ -0,0 +1,115 @@ +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; +} + +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.listYesterdayConversations( + buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') + ) + ).resolves.toEqual([]); + }); + + 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 = createKiloChatSummaryClient({ + baseUrl: 'http://controller/', + token: 'token', + fetchImpl, + }); + const conversations = await client.listYesterdayConversations( + buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') + ); + + expect(conversations).toEqual([ + { + conversationId: 'conv-1', + title: 'Launch plan', + 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('throws on non-ok controller responses', async () => { + const fetchImpl = mockFetch(async () => new Response('no route', { status: 404 })); + const client = createKiloChatSummaryClient({ + baseUrl: 'http://controller', + token: 'token', + fetchImpl, + }); + + await expect( + client.listYesterdayConversations( + 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..e0594ec61e4 --- /dev/null +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.ts @@ -0,0 +1,236 @@ +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; + title: string | null; + 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; + timeoutMs?: number; + fetchImpl?: FetchImpl; +}; + +export type KiloChatSummaryClient = { + configured: boolean; + reason: string; + listYesterdayConversations: (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 parseObject(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function parseConversationsResponse(value: unknown): ConversationsResponse { + const obj = parseObject(value); + const conversationsRaw = Array.isArray(obj.conversations) ? obj.conversations : []; + const conversations = conversationsRaw.flatMap(item => { + const row = parseObject(item); + return typeof row.conversationId === 'string' + ? [ + { + conversationId: row.conversationId, + title: typeof row.title === 'string' ? row.title : null, + 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 = parseObject(value); + const messagesRaw = Array.isArray(obj.messages) ? obj.messages : []; + const messages = messagesRaw.flatMap(item => { + const row = parseObject(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); + } +} + +function shouldInspectConversation( + conversation: KiloChatConversationListItem, + window: ChatSummaryWindow +): boolean { + return conversation.lastActivityAt !== null && conversation.lastActivityAt >= window.startMs; +} + +function shouldStopConversationScan( + conversations: KiloChatConversationListItem[], + window: ChatSummaryWindow +): boolean { + return conversations.some( + conversation => + conversation.lastActivityAt !== null && conversation.lastActivityAt < window.startMs + ); +} + +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', + listYesterdayConversations: async () => [], + }; + } + + 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 { + 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) break; + + const oldestTimestamp = ulidToTimestampMs( + parsed.messages[parsed.messages.length - 1]?.id ?? '' + ); + if (oldestTimestamp !== null && oldestTimestamp < window.startMs) { + // Pagination returns newest first. Once the page's oldest message is + // older than yesterday, later pages cannot contribute stats. + break; + } + cursor = parsed.nextCursor; + } + return messages; + } + + async function listYesterdayConversations( + window: ChatSummaryWindow + ): Promise { + const conversations: ChatSummaryConversation[] = []; + let cursor: string | null = null; + 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; + conversations.push({ + ...conversation, + messages: await listMessagesForConversation(conversation, window), + }); + } + if ( + !parsed.hasMore || + !parsed.nextCursor || + shouldStopConversationScan(parsed.conversations, window) + ) { + break; + } + cursor = parsed.nextCursor; + } + return conversations; + } + + return { + configured: true, + reason: 'Kilo Chat controller is configured', + listYesterdayConversations, + }; +} 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..e179388de37 --- /dev/null +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-utils.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { + buildChatSummarySectionLines, + buildChatSummaryStatus, + 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('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', + title: 'Launch plan', + 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', + title: null, + 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, + topConversations: [ + { title: 'Launch plan', messageCount: 3 }, + { title: 'New chat', messageCount: 1 }, + ], + }); + expect(buildChatSummaryStatus(stats)).toBe('4 Kilo Chat message(s) across 2 conversation(s)'); + expect(buildChatSummarySectionLines(stats)).toEqual([ + '- 4 messages across 2 conversations.', + '- 3 messages from you; 1 reply from Kilo.', + '- 1 deleted message excluded from content summaries.', + '', + 'Most active threads', + '- Launch plan (3 messages)', + '- New chat (1 message)', + ]); + }); + + it('renders a quiet no-activity section', () => { + const stats = summarizeChatActivity( + [], + buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') + ); + + expect(buildChatSummaryStatus(stats)).toBe('0 Kilo Chat messages yesterday'); + expect(buildChatSummarySectionLines(stats)).toEqual(['No Kilo Chat messages yesterday.']); + }); +}); 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..46c1379582b --- /dev/null +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-utils.ts @@ -0,0 +1,201 @@ +const DEFAULT_TIMEZONE = 'UTC'; +const ULID_TIME_LENGTH = 10; +const CROCKFORD_BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +export type ChatSummaryMessage = { + id: string; + senderId: string; + deleted: boolean; +}; + +export type ChatSummaryConversation = { + conversationId: string; + title: string | null; + 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; + topConversations: Array<{ title: string; messageCount: 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 getTimezoneOffset(date: Date, timezone: string): string { + try { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: timezone || DEFAULT_TIMEZONE, + timeZoneName: 'longOffset', + }).formatToParts(date); + const offset = parts.find(part => part.type === 'timeZoneName')?.value ?? ''; + const match = offset.match(/GMT([+-]\d{2}:\d{2})/); + if (!match) return 'Z'; + return match[1] === '+00:00' || match[1] === '-00:00' ? 'Z' : match[1]; + } catch { + return 'Z'; + } +} + +function utcMillisForWallTime(dateKey: string, time: string, timezone: string): number { + const offset = getTimezoneOffset(new Date(`${dateKey}T${time}Z`), timezone || DEFAULT_TIMEZONE); + return Date.parse(`${dateKey}T${time}${offset}`); +} + +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 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 displayConversationTitle(conversation: ChatSummaryConversation): string { + const title = conversation.title?.trim(); + return title && title.length > 0 ? title : 'New chat'; +} + +function isBotSender(senderId: string): boolean { + return senderId.startsWith('bot:'); +} + +export function summarizeChatActivity( + conversations: ChatSummaryConversation[], + window: ChatSummaryWindow +): ChatSummaryStats { + let messageCount = 0; + let userMessageCount = 0; + let botMessageCount = 0; + let deletedMessageCount = 0; + const perConversation = new Map(); + + 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; + } + + const existing = perConversation.get(conversation.conversationId); + if (existing) { + existing.count += 1; + } else { + perConversation.set(conversation.conversationId, { + title: displayConversationTitle(conversation), + count: 1, + }); + } + } + } + + const topConversations = Array.from(perConversation.values()) + .sort((a, b) => b.count - a.count || a.title.localeCompare(b.title)) + .slice(0, 3) + .map(item => ({ title: item.title, messageCount: item.count })); + + return { + activeConversationCount: perConversation.size, + messageCount, + userMessageCount, + botMessageCount, + deletedMessageCount, + topConversations, + }; +} + +function pluralize(count: number, singular: string, plural = `${singular}s`): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +export function buildChatSummarySectionLines(stats: ChatSummaryStats): string[] { + if (stats.messageCount === 0) { + return ['No Kilo Chat messages yesterday.']; + } + + 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) { + lines.push( + `- ${pluralize(stats.deletedMessageCount, 'deleted message')} excluded from content summaries.` + ); + } + + if (stats.topConversations.length > 0) { + lines.push('', 'Most active threads'); + for (const conversation of stats.topConversations) { + lines.push(`- ${conversation.title} (${pluralize(conversation.messageCount, 'message')})`); + } + } + + return lines; +} + +export function buildChatSummaryStatus(stats: ChatSummaryStats): string { + if (stats.messageCount === 0) return '0 Kilo Chat messages yesterday'; + return `${stats.messageCount} Kilo Chat message(s) across ${stats.activeConversationCount} conversation(s)`; +} 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..7004b357468 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'; @@ -70,6 +71,13 @@ import { fetchCalendarEvents, resolveCalendarReady, } from './calendar-client'; +import { createKiloChatSummaryClient } from './chat-summary-client'; +import { + buildChatSummarySectionLines, + buildChatSummaryStatus, + buildYesterdayChatWindow, + summarizeChatActivity, +} from './chat-summary-utils'; const PLUGIN_ID = 'kiloclaw-morning-briefing'; const CRON_JOB_NAME = 'KiloClaw Morning Briefing'; @@ -139,7 +147,7 @@ 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; @@ -169,10 +177,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 +200,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 +1339,44 @@ 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 window = buildYesterdayChatWindow(now, userTimezone); + try { + const conversations = await client.listYesterdayConversations(window); + const stats = summarizeChatActivity(conversations, window); + return { + source: 'kilo-chat', + configured: true, + ok: true, + summary: buildChatSummaryStatus(stats), + sectionLines: buildChatSummarySectionLines(stats), + sectionTitle: `Yesterday in Chat (${window.dateKey})`, + }; + } 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 +1446,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 +1464,7 @@ async function generateBriefing( const sources: SourceCollectionResult[] = [ calendar, + kiloChat, github, linear, ...(localNews ? [localNews] : []), @@ -1422,7 +1474,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,13 +1487,14 @@ async function generateBriefing( const DEFAULT_SECTION_TITLE: Record = { calendar: 'Calendar', github: 'GitHub', + 'kilo-chat': 'Yesterday in 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, @@ -1474,7 +1527,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, From b41364eab3c1774329f6a9535c626a37125cd3be Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Tue, 19 May 2026 16:29:01 -0700 Subject: [PATCH 2/6] clean up the markdown --- .../src/chat-summary-client.test.ts | 59 ++++++++++++++++++- .../src/chat-summary-client.ts | 14 +++-- .../src/chat-summary-utils.test.ts | 30 ++++++---- .../src/chat-summary-utils.ts | 54 +++++++---------- .../kiloclaw-morning-briefing/src/index.ts | 51 ++++++++++++---- 5 files changed, 143 insertions(+), 65 deletions(-) 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 index 45c37b8e2c3..c084a4e8a71 100644 --- 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 @@ -26,7 +26,7 @@ describe('chat summary client', () => { expect(client.configured).toBe(false); expect(client.reason).toBe('OPENCLAW_GATEWAY_TOKEN is not configured'); await expect( - client.listYesterdayConversations( + client.listConversationsForWindow( buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') ) ).resolves.toEqual([]); @@ -73,7 +73,7 @@ describe('chat summary client', () => { token: 'token', fetchImpl, }); - const conversations = await client.listYesterdayConversations( + const conversations = await client.listConversationsForWindow( buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') ); @@ -98,6 +98,59 @@ describe('chat summary client', () => { }); }); + it('skips current-day conversations while still fetching yesterday conversations', 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-today', + title: 'Today only', + lastActivityAt: Date.parse('2026-05-19T10:00:00.000Z'), + }, + { + conversationId: 'conv-yesterday', + title: 'Yesterday thread', + lastActivityAt: Date.parse('2026-05-18T17:00:00.000Z'), + }, + ], + hasMore: false, + nextCursor: null, + }); + } + if ( + url === 'http://controller/_kilo/kilo-chat/conversations/conv-yesterday/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 = createKiloChatSummaryClient({ + baseUrl: 'http://controller', + token: 'token', + fetchImpl, + }); + const conversations = await client.listConversationsForWindow( + buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') + ); + + expect(conversations.map(conversation => conversation.conversationId)).toEqual([ + 'conv-yesterday', + ]); + expect(requests).toEqual([ + 'http://controller/_kilo/kilo-chat/conversations?limit=100', + 'http://controller/_kilo/kilo-chat/conversations/conv-yesterday/messages?limit=100', + ]); + }); + it('throws on non-ok controller responses', async () => { const fetchImpl = mockFetch(async () => new Response('no route', { status: 404 })); const client = createKiloChatSummaryClient({ @@ -107,7 +160,7 @@ describe('chat summary client', () => { }); await expect( - client.listYesterdayConversations( + 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 index e0594ec61e4..c93153c2dd0 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.ts @@ -43,7 +43,7 @@ export type KiloChatSummaryClientOptions = { export type KiloChatSummaryClient = { configured: boolean; reason: string; - listYesterdayConversations: (window: ChatSummaryWindow) => Promise; + listConversationsForWindow: (window: ChatSummaryWindow) => Promise; }; function normalizeBaseUrl(input: string | undefined): string { @@ -130,7 +130,11 @@ function shouldInspectConversation( conversation: KiloChatConversationListItem, window: ChatSummaryWindow ): boolean { - return conversation.lastActivityAt !== null && conversation.lastActivityAt >= window.startMs; + return ( + conversation.lastActivityAt !== null && + conversation.lastActivityAt >= window.startMs && + conversation.lastActivityAt < window.endMs + ); } function shouldStopConversationScan( @@ -151,7 +155,7 @@ export function createKiloChatSummaryClient( return { configured: false, reason: 'OPENCLAW_GATEWAY_TOKEN is not configured', - listYesterdayConversations: async () => [], + listConversationsForWindow: async () => [], }; } @@ -194,7 +198,7 @@ export function createKiloChatSummaryClient( return messages; } - async function listYesterdayConversations( + async function listConversationsForWindow( window: ChatSummaryWindow ): Promise { const conversations: ChatSummaryConversation[] = []; @@ -231,6 +235,6 @@ export function createKiloChatSummaryClient( return { configured: true, reason: 'Kilo Chat controller is configured', - listYesterdayConversations, + 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 index e179388de37..0fbc489a75d 100644 --- 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 @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { buildChatSummarySectionLines, buildChatSummaryStatus, + buildTodaySoFarChatWindow, buildYesterdayChatWindow, summarizeChatActivity, ulidToTimestampMs, @@ -38,6 +39,15 @@ describe('chat summary utils', () => { expect(new Date(window.endMs).toISOString()).toBe('2026-05-19T07: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[] = [ @@ -90,20 +100,14 @@ describe('chat summary utils', () => { userMessageCount: 3, botMessageCount: 1, deletedMessageCount: 1, - topConversations: [ - { title: 'Launch plan', messageCount: 3 }, - { title: 'New chat', messageCount: 1 }, - ], }); - expect(buildChatSummaryStatus(stats)).toBe('4 Kilo Chat message(s) across 2 conversation(s)'); - expect(buildChatSummarySectionLines(stats)).toEqual([ + expect(buildChatSummaryStatus(stats, 'yesterday')).toBe( + '4 Kilo Chat message(s) across 2 conversation(s)' + ); + expect(buildChatSummarySectionLines(stats, 'No Kilo Chat messages yesterday.')).toEqual([ '- 4 messages across 2 conversations.', '- 3 messages from you; 1 reply from Kilo.', '- 1 deleted message excluded from content summaries.', - '', - 'Most active threads', - '- Launch plan (3 messages)', - '- New chat (1 message)', ]); }); @@ -113,7 +117,9 @@ describe('chat summary utils', () => { buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') ); - expect(buildChatSummaryStatus(stats)).toBe('0 Kilo Chat messages yesterday'); - expect(buildChatSummarySectionLines(stats)).toEqual(['No Kilo Chat messages yesterday.']); + 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 index 46c1379582b..6a3add8cf6e 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-utils.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-utils.ts @@ -27,7 +27,6 @@ export type ChatSummaryStats = { userMessageCount: number; botMessageCount: number; deletedMessageCount: number; - topConversations: Array<{ title: string; messageCount: number }>; }; function readDatePart(parts: Intl.DateTimeFormatPart[], type: 'year' | 'month' | 'day'): string { @@ -86,6 +85,16 @@ export function buildYesterdayChatWindow(now: Date, timezone: string): ChatSumma }; } +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; @@ -97,11 +106,6 @@ export function ulidToTimestampMs(ulid: string): number | null { return Number.isSafeInteger(value) ? value : null; } -function displayConversationTitle(conversation: ChatSummaryConversation): string { - const title = conversation.title?.trim(); - return title && title.length > 0 ? title : 'New chat'; -} - function isBotSender(senderId: string): boolean { return senderId.startsWith('bot:'); } @@ -114,7 +118,7 @@ export function summarizeChatActivity( let userMessageCount = 0; let botMessageCount = 0; let deletedMessageCount = 0; - const perConversation = new Map(); + const activeConversationIds = new Set(); for (const conversation of conversations) { for (const message of conversation.messages) { @@ -131,30 +135,16 @@ export function summarizeChatActivity( userMessageCount += 1; } - const existing = perConversation.get(conversation.conversationId); - if (existing) { - existing.count += 1; - } else { - perConversation.set(conversation.conversationId, { - title: displayConversationTitle(conversation), - count: 1, - }); - } + activeConversationIds.add(conversation.conversationId); } } - const topConversations = Array.from(perConversation.values()) - .sort((a, b) => b.count - a.count || a.title.localeCompare(b.title)) - .slice(0, 3) - .map(item => ({ title: item.title, messageCount: item.count })); - return { - activeConversationCount: perConversation.size, + activeConversationCount: activeConversationIds.size, messageCount, userMessageCount, botMessageCount, deletedMessageCount, - topConversations, }; } @@ -162,9 +152,12 @@ function pluralize(count: number, singular: string, plural = `${singular}s`): st return `${count} ${count === 1 ? singular : plural}`; } -export function buildChatSummarySectionLines(stats: ChatSummaryStats): string[] { +export function buildChatSummarySectionLines( + stats: ChatSummaryStats, + emptyMessage: string +): string[] { if (stats.messageCount === 0) { - return ['No Kilo Chat messages yesterday.']; + return [emptyMessage]; } const lines = [ @@ -185,17 +178,10 @@ export function buildChatSummarySectionLines(stats: ChatSummaryStats): string[] ); } - if (stats.topConversations.length > 0) { - lines.push('', 'Most active threads'); - for (const conversation of stats.topConversations) { - lines.push(`- ${conversation.title} (${pluralize(conversation.messageCount, 'message')})`); - } - } - return lines; } -export function buildChatSummaryStatus(stats: ChatSummaryStats): string { - if (stats.messageCount === 0) return '0 Kilo Chat messages yesterday'; +export function buildChatSummaryStatus(stats: ChatSummaryStats, periodLabel: string): string { + if (stats.messageCount === 0) return `0 Kilo Chat messages ${periodLabel}`; return `${stats.messageCount} Kilo Chat message(s) across ${stats.activeConversationCount} conversation(s)`; } diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts index 7004b357468..e97f13bcbb6 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts @@ -11,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, @@ -75,6 +80,7 @@ import { createKiloChatSummaryClient } from './chat-summary-client'; import { buildChatSummarySectionLines, buildChatSummaryStatus, + buildTodaySoFarChatWindow, buildYesterdayChatWindow, summarizeChatActivity, } from './chat-summary-utils'; @@ -152,6 +158,7 @@ type SourceCollectionResult = { 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` @@ -1354,17 +1361,34 @@ async function collectKiloChatSummary( }; } - const window = buildYesterdayChatWindow(now, userTimezone); + const yesterdayWindow = buildYesterdayChatWindow(now, userTimezone); + const todayWindow = buildTodaySoFarChatWindow(now, userTimezone); try { - const conversations = await client.listYesterdayConversations(window); - const stats = summarizeChatActivity(conversations, window); + const [yesterdayConversations, todayConversations] = await Promise.all([ + client.listConversationsForWindow(yesterdayWindow), + client.listConversationsForWindow(todayWindow), + ]); + const yesterdayStats = summarizeChatActivity(yesterdayConversations, yesterdayWindow); + const todayStats = summarizeChatActivity(todayConversations, todayWindow); return { source: 'kilo-chat', configured: true, ok: true, - summary: buildChatSummaryStatus(stats), - sectionLines: buildChatSummarySectionLines(stats), - sectionTitle: `Yesterday in Chat (${window.dateKey})`, + summary: `Yesterday: ${buildChatSummaryStatus( + yesterdayStats, + 'yesterday' + )}; today: ${buildChatSummaryStatus(todayStats, 'so far today')}`, + 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 { @@ -1501,10 +1525,15 @@ async function generateBriefing( 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, }); From 35283f3b524b023362f37dc2706297b78bedf686 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Tue, 19 May 2026 16:40:06 -0700 Subject: [PATCH 3/6] fix kilobot findings --- .../src/chat-summary-client.test.ts | 69 +++++++++++++++++++ .../src/chat-summary-client.ts | 19 +++-- 2 files changed, 83 insertions(+), 5 deletions(-) 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 index c084a4e8a71..7fdb1c96949 100644 --- 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 @@ -151,6 +151,75 @@ describe('chat summary client', () => { ]); }); + 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 = createKiloChatSummaryClient({ + baseUrl: 'http://controller', + token: 'token', + fetchImpl, + }); + const conversations = await client.listConversationsForWindow( + buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') + ); + + expect(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('throws on non-ok controller responses', async () => { const fetchImpl = mockFetch(async () => new Response('no route', { status: 404 })); const client = createKiloChatSummaryClient({ 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 index c93153c2dd0..a6bcf6d0b97 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.ts @@ -51,17 +51,17 @@ function normalizeBaseUrl(input: string | undefined): string { return raw.endsWith('/') ? raw.slice(0, -1) : raw; } -function parseObject(value: unknown): Record { +function asObject(value: unknown): Record { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {}; } function parseConversationsResponse(value: unknown): ConversationsResponse { - const obj = parseObject(value); + const obj = asObject(value); const conversationsRaw = Array.isArray(obj.conversations) ? obj.conversations : []; const conversations = conversationsRaw.flatMap(item => { - const row = parseObject(item); + const row = asObject(item); return typeof row.conversationId === 'string' ? [ { @@ -80,10 +80,10 @@ function parseConversationsResponse(value: unknown): ConversationsResponse { } function parseMessagesResponse(value: unknown): MessagesResponse { - const obj = parseObject(value); + const obj = asObject(value); const messagesRaw = Array.isArray(obj.messages) ? obj.messages : []; const messages = messagesRaw.flatMap(item => { - const row = parseObject(item); + const row = asObject(item); return typeof row.id === 'string' && typeof row.senderId === 'string' ? [ { @@ -141,6 +141,15 @@ 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 From aa6492a942e2a95da3d5d4f6e10d7c744ed3c7f7 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Wed, 20 May 2026 06:16:36 -0700 Subject: [PATCH 4/6] fix(kiloclaw): harden chat summary pagination and reporting --- .../src/chat-summary-client.test.ts | 162 +++++++++++++++++- .../src/chat-summary-client.ts | 69 +++++--- .../src/chat-summary-utils.test.ts | 6 +- .../src/chat-summary-utils.ts | 16 +- .../kiloclaw-morning-briefing/src/index.ts | 16 +- 5 files changed, 224 insertions(+), 45 deletions(-) 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 index 7fdb1c96949..86402bfff14 100644 --- 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 @@ -19,6 +19,18 @@ function fetchInputUrl(input: string | Request | URL): string { 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}`; +} + describe('chat summary client', () => { it('reports unconfigured when the gateway token is missing', async () => { const client = createKiloChatSummaryClient({ token: '' }); @@ -29,7 +41,7 @@ describe('chat summary client', () => { client.listConversationsForWindow( buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') ) - ).resolves.toEqual([]); + ).resolves.toEqual({ conversations: [], truncated: false }); }); it('lists active conversations and messages through the controller proxy', async () => { @@ -73,14 +85,14 @@ describe('chat summary client', () => { token: 'token', fetchImpl, }); - const conversations = await client.listConversationsForWindow( + const result = await client.listConversationsForWindow( buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') ); - expect(conversations).toEqual([ + expect(result.truncated).toBe(false); + expect(result.conversations).toEqual([ { conversationId: 'conv-1', - title: 'Launch plan', lastActivityAt: Date.parse('2026-05-18T09:00:00.000Z'), messages: [ { id: '01JVNY65G00000000000000000', senderId: 'user:1', deleted: false }, @@ -94,7 +106,7 @@ describe('chat summary client', () => { ]); expect(requests[0]?.init).toMatchObject({ method: 'GET', - headers: { authorization: 'Bearer token' }, + headers: { Authorization: 'Bearer token' }, }); }); @@ -138,11 +150,11 @@ describe('chat summary client', () => { token: 'token', fetchImpl, }); - const conversations = await client.listConversationsForWindow( + const result = await client.listConversationsForWindow( buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') ); - expect(conversations.map(conversation => conversation.conversationId)).toEqual([ + expect(result.conversations.map(conversation => conversation.conversationId)).toEqual([ 'conv-yesterday', ]); expect(requests).toEqual([ @@ -207,11 +219,11 @@ describe('chat summary client', () => { token: 'token', fetchImpl, }); - const conversations = await client.listConversationsForWindow( + const result = await client.listConversationsForWindow( buildYesterdayChatWindow(new Date('2026-05-19T12:00:00.000Z'), 'UTC') ); - expect(conversations.map(conversation => conversation.conversationId)).toEqual([ + expect(result.conversations.map(conversation => conversation.conversationId)).toEqual([ 'conv-yesterday-later', 'conv-yesterday-next-page', ]); @@ -220,6 +232,138 @@ describe('chat summary client', () => { ); }); + 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 = createKiloChatSummaryClient({ + 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 = createKiloChatSummaryClient({ + 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 = createKiloChatSummaryClient({ 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 index a6bcf6d0b97..482f2f9344e 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.ts @@ -15,7 +15,6 @@ type FetchImpl = typeof fetch; type KiloChatConversationListItem = { conversationId: string; - title: string | null; lastActivityAt: number | null; }; @@ -40,10 +39,16 @@ export type KiloChatSummaryClientOptions = { 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; + listConversationsForWindow: (window: ChatSummaryWindow) => Promise; }; function normalizeBaseUrl(input: string | undefined): string { @@ -66,7 +71,6 @@ function parseConversationsResponse(value: unknown): ConversationsResponse { ? [ { conversationId: row.conversationId, - title: typeof row.title === 'string' ? row.title : null, lastActivityAt: typeof row.lastActivityAt === 'number' ? row.lastActivityAt : null, }, ] @@ -112,7 +116,7 @@ async function fetchJson( try { const response = await fetchImpl(url, { method: 'GET', - headers: { authorization: `Bearer ${token}` }, + headers: { Authorization: `Bearer ${token}` }, signal: controller.signal, }); if (!response.ok) { @@ -156,6 +160,27 @@ function shouldStopConversationScan( ); } +/** + * 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 { @@ -164,7 +189,7 @@ export function createKiloChatSummaryClient( return { configured: false, reason: 'OPENCLAW_GATEWAY_TOKEN is not configured', - listConversationsForWindow: async () => [], + listConversationsForWindow: async () => ({ conversations: [], truncated: false }), }; } @@ -176,7 +201,7 @@ export function createKiloChatSummaryClient( async function listMessagesForConversation( conversation: KiloChatConversationListItem, window: ChatSummaryWindow - ): Promise { + ): 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) { @@ -192,26 +217,24 @@ export function createKiloChatSummaryClient( ); const parsed = parseMessagesResponse(payload); messages.push(...parsed.messages); - if (!parsed.hasMore || !parsed.nextCursor || parsed.messages.length === 0) break; - - const oldestTimestamp = ulidToTimestampMs( - parsed.messages[parsed.messages.length - 1]?.id ?? '' - ); - if (oldestTimestamp !== null && oldestTimestamp < window.startMs) { - // Pagination returns newest first. Once the page's oldest message is - // older than yesterday, later pages cannot contribute stats. - break; + 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; } - return messages; + // Fell out of the loop because the page cap was hit; older messages remain. + return { messages, truncated: true }; } async function listConversationsForWindow( window: ChatSummaryWindow - ): Promise { + ): 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); @@ -224,21 +247,21 @@ export function createKiloChatSummaryClient( const parsed = parseConversationsResponse(payload); for (const conversation of parsed.conversations) { if (!shouldInspectConversation(conversation, window)) continue; - conversations.push({ - ...conversation, - messages: await listMessagesForConversation(conversation, window), - }); + 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) ) { - break; + return { conversations, truncated }; } cursor = parsed.nextCursor; } - return conversations; + // Fell out of the loop because the page cap was hit; older conversations remain. + return { conversations, truncated: true }; } return { 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 index 0fbc489a75d..f77db05d15c 100644 --- 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 @@ -53,7 +53,6 @@ describe('chat summary utils', () => { const conversations: ChatSummaryConversation[] = [ { conversationId: 'conv-1', - title: 'Launch plan', lastActivityAt: Date.parse('2026-05-18T23:00:00.000Z'), messages: [ { @@ -80,7 +79,6 @@ describe('chat summary utils', () => { }, { conversationId: 'conv-2', - title: null, lastActivityAt: Date.parse('2026-05-18T20:00:00.000Z'), messages: [ { @@ -102,12 +100,12 @@ describe('chat summary utils', () => { deletedMessageCount: 1, }); expect(buildChatSummaryStatus(stats, 'yesterday')).toBe( - '4 Kilo Chat message(s) across 2 conversation(s)' + '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 deleted message excluded from content summaries.', + '- 1 of those messages was later deleted; its content is excluded from summaries.', ]); }); 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 index 6a3add8cf6e..7adc9a5f6ef 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-utils.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-utils.ts @@ -1,6 +1,7 @@ const DEFAULT_TIMEZONE = 'UTC'; const ULID_TIME_LENGTH = 10; const CROCKFORD_BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; +const BOT_SENDER_PREFIX = 'bot:'; export type ChatSummaryMessage = { id: string; @@ -10,7 +11,6 @@ export type ChatSummaryMessage = { export type ChatSummaryConversation = { conversationId: string; - title: string | null; lastActivityAt: number | null; messages: ChatSummaryMessage[]; }; @@ -107,7 +107,7 @@ export function ulidToTimestampMs(ulid: string): number | null { } function isBotSender(senderId: string): boolean { - return senderId.startsWith('bot:'); + return senderId.startsWith(BOT_SENDER_PREFIX); } export function summarizeChatActivity( @@ -173,8 +173,13 @@ export function buildChatSummarySectionLines( ]; 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( - `- ${pluralize(stats.deletedMessageCount, 'deleted message')} excluded from content summaries.` + 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.` ); } @@ -183,5 +188,8 @@ export function buildChatSummarySectionLines( export function buildChatSummaryStatus(stats: ChatSummaryStats, periodLabel: string): string { if (stats.messageCount === 0) return `0 Kilo Chat messages ${periodLabel}`; - return `${stats.messageCount} Kilo Chat message(s) across ${stats.activeConversationCount} conversation(s)`; + return `${pluralize(stats.messageCount, 'Kilo Chat message')} across ${pluralize( + stats.activeConversationCount, + 'conversation' + )}`; } diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts index e97f13bcbb6..f54cd207975 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts @@ -1364,12 +1364,16 @@ async function collectKiloChatSummary( const yesterdayWindow = buildYesterdayChatWindow(now, userTimezone); const todayWindow = buildTodaySoFarChatWindow(now, userTimezone); try { - const [yesterdayConversations, todayConversations] = await Promise.all([ + const [yesterday, today] = await Promise.all([ client.listConversationsForWindow(yesterdayWindow), client.listConversationsForWindow(todayWindow), ]); - const yesterdayStats = summarizeChatActivity(yesterdayConversations, yesterdayWindow); - const todayStats = summarizeChatActivity(todayConversations, 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, @@ -1377,7 +1381,7 @@ async function collectKiloChatSummary( summary: `Yesterday: ${buildChatSummaryStatus( yesterdayStats, 'yesterday' - )}; today: ${buildChatSummaryStatus(todayStats, 'so far today')}`, + )}; today: ${buildChatSummaryStatus(todayStats, 'so far today')}${truncatedNote}`, sectionLines: [], sections: [ { @@ -1511,7 +1515,9 @@ async function generateBriefing( const DEFAULT_SECTION_TITLE: Record = { calendar: 'Calendar', github: 'GitHub', - 'kilo-chat': 'Yesterday in Chat', + // `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', From 0fdad49e10919594eb4ef1958a5c52bb74c64d75 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Wed, 20 May 2026 06:21:39 -0700 Subject: [PATCH 5/6] fix(kiloclaw): scan conversations spanning the chat summary window --- .../src/chat-summary-client.test.ts | 40 ++++++++++++++----- .../src/chat-summary-client.ts | 14 ++++--- 2 files changed, 38 insertions(+), 16 deletions(-) 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 index 86402bfff14..49bfbc792f0 100644 --- 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 @@ -110,7 +110,18 @@ describe('chat summary client', () => { }); }); - it('skips current-day conversations while still fetching yesterday conversations', async () => { + 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); @@ -119,14 +130,12 @@ describe('chat summary client', () => { return jsonResponse({ conversations: [ { - conversationId: 'conv-today', - title: 'Today only', - lastActivityAt: Date.parse('2026-05-19T10:00:00.000Z'), + conversationId: 'conv-spanning', + lastActivityAt: Date.parse('2026-05-19T08:00:00.000Z'), }, { - conversationId: 'conv-yesterday', - title: 'Yesterday thread', - lastActivityAt: Date.parse('2026-05-18T17:00:00.000Z'), + conversationId: 'conv-before-window', + lastActivityAt: Date.parse('2026-05-17T09:00:00.000Z'), }, ], hasMore: false, @@ -134,10 +143,13 @@ describe('chat summary client', () => { }); } if ( - url === 'http://controller/_kilo/kilo-chat/conversations/conv-yesterday/messages?limit=100' + url === 'http://controller/_kilo/kilo-chat/conversations/conv-spanning/messages?limit=100' ) { return jsonResponse({ - messages: [{ id: '01JVPPRVG00000000000000000', senderId: 'user:1', deleted: false }], + messages: [ + { id: todayMessage, senderId: 'user:1', deleted: false }, + { id: yesterdayMessage, senderId: 'user:1', deleted: false }, + ], hasMore: false, nextCursor: null, }); @@ -154,12 +166,18 @@ describe('chat summary client', () => { 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-yesterday', + '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-yesterday/messages?limit=100', + 'http://controller/_kilo/kilo-chat/conversations/conv-spanning/messages?limit=100', ]); }); 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 index 482f2f9344e..ed5ad9baa04 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.ts @@ -130,15 +130,19 @@ async function fetchJson( } } +/** + * 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 && - conversation.lastActivityAt < window.endMs - ); + return conversation.lastActivityAt !== null && conversation.lastActivityAt >= window.startMs; } function shouldStopConversationScan( From 0a0d70dacbeb690ba4af64f40615c526b634f55a Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Wed, 20 May 2026 07:45:06 -0700 Subject: [PATCH 6/6] fix(kiloclaw): harden chat summary windowing and availability --- .../src/chat-summary-client.test.ts | 41 +++++++++++++--- .../src/chat-summary-client.ts | 20 ++++++++ .../src/chat-summary-utils.test.ts | 11 +++++ .../src/chat-summary-utils.ts | 47 +++++++++++++------ 4 files changed, 98 insertions(+), 21 deletions(-) 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 index 49bfbc792f0..df65ce9f750 100644 --- 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 @@ -31,6 +31,16 @@ function ulidFromTimestamp(timestamp: number, suffix = '0000000000000000'): stri 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: '' }); @@ -44,6 +54,25 @@ describe('chat summary client', () => { ).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) => { @@ -80,7 +109,7 @@ describe('chat summary client', () => { throw new Error(`Unexpected URL ${url}`); }); - const client = createKiloChatSummaryClient({ + const client = createConfiguredClient({ baseUrl: 'http://controller/', token: 'token', fetchImpl, @@ -157,7 +186,7 @@ describe('chat summary client', () => { throw new Error(`Unexpected URL ${url}`); }); - const client = createKiloChatSummaryClient({ + const client = createConfiguredClient({ baseUrl: 'http://controller', token: 'token', fetchImpl, @@ -232,7 +261,7 @@ describe('chat summary client', () => { throw new Error(`Unexpected URL ${url}`); }); - const client = createKiloChatSummaryClient({ + const client = createConfiguredClient({ baseUrl: 'http://controller', token: 'token', fetchImpl, @@ -318,7 +347,7 @@ describe('chat summary client', () => { throw new Error(`Unexpected URL ${url}`); }); - const client = createKiloChatSummaryClient({ + const client = createConfiguredClient({ baseUrl: 'http://controller', token: 'token', fetchImpl, @@ -370,7 +399,7 @@ describe('chat summary client', () => { }); }); - const client = createKiloChatSummaryClient({ + const client = createConfiguredClient({ baseUrl: 'http://controller', token: 'token', fetchImpl, @@ -384,7 +413,7 @@ describe('chat summary client', () => { it('throws on non-ok controller responses', async () => { const fetchImpl = mockFetch(async () => new Response('no route', { status: 404 })); - const client = createKiloChatSummaryClient({ + const client = createConfiguredClient({ baseUrl: 'http://controller', token: 'token', fetchImpl, 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 index ed5ad9baa04..66c0e285742 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-client.ts @@ -35,6 +35,8 @@ type MessagesResponse = { export type KiloChatSummaryClientOptions = { baseUrl?: string; token?: string; + sandboxId?: string; + kiloChatBaseUrl?: string; timeoutMs?: number; fetchImpl?: FetchImpl; }; @@ -197,6 +199,24 @@ export function createKiloChatSummaryClient( }; } + 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); 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 index f77db05d15c..aa80243824a 100644 --- 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 @@ -39,6 +39,17 @@ describe('chat summary utils', () => { 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'); 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 index 7adc9a5f6ef..00e6e1565b8 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-utils.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/chat-summary-utils.ts @@ -54,24 +54,41 @@ function addDays(dateKey: string, days: number): string { ).padStart(2, '0')}`; } -function getTimezoneOffset(date: Date, timezone: string): string { - try { +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: timezone || DEFAULT_TIMEZONE, - timeZoneName: 'longOffset', - }).formatToParts(date); - const offset = parts.find(part => part.type === 'timeZoneName')?.value ?? ''; - const match = offset.match(/GMT([+-]\d{2}:\d{2})/); - if (!match) return 'Z'; - return match[1] === '+00:00' || match[1] === '-00:00' ? 'Z' : match[1]; - } catch { - return 'Z'; + 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; } -} -function utcMillisForWallTime(dateKey: string, time: string, timezone: string): number { - const offset = getTimezoneOffset(new Date(`${dateKey}T${time}Z`), timezone || DEFAULT_TIMEZONE); - return Date.parse(`${dateKey}T${time}${offset}`); + return utcMs; } export function buildYesterdayChatWindow(now: Date, timezone: string): ChatSummaryWindow {