From 625a2b636b13c816d87ff764b75fe7a5debd9c44 Mon Sep 17 00:00:00 2001 From: Beatrix Date: Fri, 22 Mar 2024 13:39:40 -0700 Subject: [PATCH 1/5] Chat: fix at-mention token size --- lib/shared/src/codebase-context/messages.ts | 2 ++ vscode/src/chat/chat-view/SimpleChatModel.ts | 17 +++++++++++++++- .../chat/chat-view/SimpleChatPanelProvider.ts | 10 +++++++--- vscode/src/chat/context/chatContext.ts | 7 ++++--- vscode/src/editor/utils/editor-context.ts | 20 ++++++++++++------- 5 files changed, 42 insertions(+), 14 deletions(-) diff --git a/lib/shared/src/codebase-context/messages.ts b/lib/shared/src/codebase-context/messages.ts index 49bb4371cc97..fa31c0173cbc 100644 --- a/lib/shared/src/codebase-context/messages.ts +++ b/lib/shared/src/codebase-context/messages.ts @@ -84,6 +84,8 @@ export interface ContextItemFile extends ContextItemCommon { * Whether the file is too large to be included as context. */ isTooLarge?: boolean + + size?: number } /** diff --git a/vscode/src/chat/chat-view/SimpleChatModel.ts b/vscode/src/chat/chat-view/SimpleChatModel.ts index fa75b655c58f..17a3a2f4e271 100644 --- a/vscode/src/chat/chat-view/SimpleChatModel.ts +++ b/vscode/src/chat/chat-view/SimpleChatModel.ts @@ -4,6 +4,7 @@ import { type ChatMessage, type ContextItem, type Message, + ModelProvider, type SerializedChatInteraction, type SerializedChatTranscript, errorToChatError, @@ -15,13 +16,27 @@ import type { Repo } from '../../context/repo-fetcher' import { getChatPanelTitle } from './chat-helpers' export class SimpleChatModel { + public readonly maxChars: number constructor( public modelID: string, private messages: ChatMessage[] = [], public readonly sessionID: string = new Date(Date.now()).toUTCString(), private customChatTitle?: string, private selectedRepos?: Repo[] - ) {} + ) { + this.maxChars = ModelProvider.getMaxCharsByModel(this.modelID) + } + + public get charsLeft(): number { + let used = 0 + for (const msg of this.messages) { + if (used > this.maxChars) { + return 0 + } + used += msg.speaker.length + (msg.text?.length || 0) + 3 + } + return this.maxChars - used + } public isEmpty(): boolean { return this.messages.length === 0 diff --git a/vscode/src/chat/chat-view/SimpleChatPanelProvider.ts b/vscode/src/chat/chat-view/SimpleChatPanelProvider.ts index 9cb0ca8f270f..561a0020da22 100644 --- a/vscode/src/chat/chat-view/SimpleChatPanelProvider.ts +++ b/vscode/src/chat/chat-view/SimpleChatPanelProvider.ts @@ -52,6 +52,7 @@ import type { Span } from '@opentelemetry/api' import { captureException } from '@sentry/core' import type { ContextItemWithContent } from '@sourcegraph/cody-shared/src/codebase-context/messages' import { ModelUsage } from '@sourcegraph/cody-shared/src/models/types' +import { ANSWER_TOKENS } from '@sourcegraph/cody-shared/src/prompt/constants' import { recordErrorToSpan, tracer } from '@sourcegraph/cody-shared/src/tracing' import type { EnterpriseContextFactory } from '../../context/enterprise-context-factory' import type { Repo } from '../../context/repo-fetcher' @@ -601,7 +602,8 @@ export class SimpleChatPanelProvider implements vscode.Disposable, ChatSession { const items = await getChatContextItemsForMention( query, cancellation.token, - scopedTelemetryRecorder + scopedTelemetryRecorder, + this.chatModel.charsLeft - ANSWER_TOKENS ) if (cancellation.token.isCancellationRequested) { return @@ -779,8 +781,10 @@ export class SimpleChatPanelProvider implements vscode.Disposable, ChatSession { prompter: IPrompter, sendTelemetry?: (contextSummary: any) => void ): Promise { - const maxChars = ModelProvider.getMaxCharsByModel(this.chatModel.modelID) - const { prompt, newContextUsed } = await prompter.makePrompt(this.chatModel, maxChars) + const { prompt, newContextUsed } = await prompter.makePrompt( + this.chatModel, + this.chatModel.maxChars + ) // Update UI based on prompt construction this.chatModel.setLastMessageContext(newContextUsed) diff --git a/vscode/src/chat/context/chatContext.ts b/vscode/src/chat/context/chatContext.ts index ff7adee73dcb..a09d8c5ccec7 100644 --- a/vscode/src/chat/context/chatContext.ts +++ b/vscode/src/chat/context/chatContext.ts @@ -19,7 +19,8 @@ export async function getChatContextItemsForMention( telemetryRecorder?: { empty: () => void withType: (type: MentionQuery['type']) => void - } + }, + charsLimit?: number ): Promise { const mentionQuery = parseMentionQuery(query) @@ -35,12 +36,12 @@ export async function getChatContextItemsForMention( const MAX_RESULTS = 20 switch (mentionQuery.type) { case 'empty': - return getOpenTabsContextFile() + return getOpenTabsContextFile(charsLimit) case 'symbol': // It would be nice if the VS Code symbols API supports cancellation, but it doesn't return getSymbolContextFiles(mentionQuery.text, MAX_RESULTS) case 'file': - return getFileContextFiles(mentionQuery.text, MAX_RESULTS, cancellationToken) + return getFileContextFiles(mentionQuery.text, MAX_RESULTS, cancellationToken, charsLimit) case 'url': return (await isURLContextFeatureFlagEnabled()) ? getURLContextItems( diff --git a/vscode/src/editor/utils/editor-context.ts b/vscode/src/editor/utils/editor-context.ts index c10bad3caa0d..46dfbac17ae0 100644 --- a/vscode/src/editor/utils/editor-context.ts +++ b/vscode/src/editor/utils/editor-context.ts @@ -56,7 +56,8 @@ const throttledFindFiles = throttle(() => findWorkspaceFiles(), 10000) export async function getFileContextFiles( query: string, maxResults: number, - token: vscode.CancellationToken + cancellationToken: vscode.CancellationToken, + charsLimit?: number ): Promise { if (!query.trim()) { return [] @@ -116,7 +117,7 @@ export async function getFileContextFiles( // TODO(toolmantim): Add fuzzysort.highlight data to the result so we can show it in the UI - return await filterLargeFiles(sortedResults) + return await filterLargeFiles(sortedResults, charsLimit) } export async function getSymbolContextFiles( @@ -182,11 +183,12 @@ export async function getSymbolContextFiles( * Gets context files for each open editor tab in VS Code. * Filters out large files over 1MB to avoid expensive parsing. */ -export async function getOpenTabsContextFile(): Promise { +export async function getOpenTabsContextFile(charsLimit?: number): Promise { return await filterLargeFiles( getOpenTabsUris() .filter(uri => !isCodyIgnoredFile(uri)) - .flatMap(uri => createContextFileFromUri(uri, ContextItemSource.User, 'file')) + .flatMap(uri => createContextFileFromUri(uri, ContextItemSource.User, 'file')), + charsLimit ) } @@ -253,7 +255,10 @@ function createContextFileRange(selectionRange: vscode.Range): ContextItem['rang * Filters the given context files to remove files larger than 1MB and non-text files. * Sets {@link ContextItemFile.isTooLarge} for files contains more characters than the token limit. */ -export async function filterLargeFiles(contextFiles: ContextItemFile[]): Promise { +export async function filterLargeFiles( + contextFiles: ContextItemFile[], + charsLimit = CHARS_PER_TOKEN * MAX_CURRENT_FILE_TOKENS +): Promise { const filtered = [] for (const cf of contextFiles) { // Remove file larger than 1MB and non-text files @@ -262,13 +267,14 @@ export async function filterLargeFiles(contextFiles: ContextItemFile[]): Promise stat => stat, error => undefined ) - if (fileStat?.type !== vscode.FileType.File || fileStat?.size > 1000000) { + if (cf.type !== 'file' || fileStat?.type !== vscode.FileType.File) { continue } // Check if file contains more characters than the token limit based on fileStat.size // and set {@link ContextItemFile.isTooLarge} for webview to display file size // warning. - if (fileStat.size > CHARS_PER_TOKEN * MAX_CURRENT_FILE_TOKENS) { + cf.size = fileStat.size + if (fileStat.size > charsLimit) { cf.isTooLarge = true } filtered.push(cf) From f914329f2aa3735b0ee903eb2486f66809ac52f4 Mon Sep 17 00:00:00 2001 From: Beatrix Date: Fri, 22 Mar 2024 16:49:22 -0700 Subject: [PATCH 2/5] update tests --- vscode/src/editor/utils/editor-context.test.ts | 6 ++++-- vscode/src/editor/utils/editor-context.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/vscode/src/editor/utils/editor-context.test.ts b/vscode/src/editor/utils/editor-context.test.ts index b6e739a0a76a..360d95c8cc4d 100644 --- a/vscode/src/editor/utils/editor-context.test.ts +++ b/vscode/src/editor/utils/editor-context.test.ts @@ -158,13 +158,14 @@ describe('filterLargeFiles', () => { expect(filtered).toEqual([]) }) - it('sets isTooLarge for files exceeding token limit', async () => { + it('sets isTooLarge for files exceeding token limit but under 1MB', async () => { const largeTextFile: ContextItemFile = { uri: vscode.Uri.file('/large-text.txt'), type: 'file', } + const oneByteOverTokenLimit = MAX_CURRENT_FILE_TOKENS * CHARS_PER_TOKEN + 1 vscode.workspace.fs.stat = vi.fn().mockResolvedValueOnce({ - size: MAX_CURRENT_FILE_TOKENS * CHARS_PER_TOKEN + 1, + size: oneByteOverTokenLimit, type: vscode.FileType.File, } as vscode.FileStat) @@ -174,6 +175,7 @@ describe('filterLargeFiles', () => { type: 'file', uri: largeTextFile.uri, isTooLarge: true, + size: oneByteOverTokenLimit, }) }) }) diff --git a/vscode/src/editor/utils/editor-context.ts b/vscode/src/editor/utils/editor-context.ts index 46dfbac17ae0..b7cf2ca7bad8 100644 --- a/vscode/src/editor/utils/editor-context.ts +++ b/vscode/src/editor/utils/editor-context.ts @@ -267,7 +267,7 @@ export async function filterLargeFiles( stat => stat, error => undefined ) - if (cf.type !== 'file' || fileStat?.type !== vscode.FileType.File) { + if (cf.type !== 'file' || fileStat?.type !== vscode.FileType.File || fileStat?.size > 1000000) { continue } // Check if file contains more characters than the token limit based on fileStat.size From bf8245d7d4e9f5cc6a4b5e54f1ee7d6d0430a62c Mon Sep 17 00:00:00 2001 From: Beatrix Date: Fri, 22 Mar 2024 17:01:04 -0700 Subject: [PATCH 3/5] update bindings --- .../com/sourcegraph/cody/protocol_generated/ContextItem.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/ContextItem.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/ContextItem.kt index 155f2ecacf5c..2c33182df088 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/ContextItem.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/ContextItem.kt @@ -31,6 +31,7 @@ data class ContextItemFile( val source: ContextItemSource? = null, // Oneof: embeddings, user, keyword, editor, filename, search, unified, selection, terminal val type: TypeEnum? = null, // Oneof: file val isTooLarge: Boolean? = null, + val size: Int? = null, ) : ContextItem() { enum class TypeEnum { From 709b8d42e511e94e7fd8086bc8cbd7dbc0414fb2 Mon Sep 17 00:00:00 2001 From: Beatrix Date: Tue, 26 Mar 2024 11:25:12 -0700 Subject: [PATCH 4/5] clean up --- .../com/sourcegraph/cody/protocol_generated/ContextItem.kt | 1 - lib/shared/src/codebase-context/messages.ts | 2 -- vscode/src/chat/chat-view/SimpleChatModel.ts | 1 + vscode/src/chat/context/chatContext.ts | 7 ++++--- vscode/src/edit/input/get-matching-context.ts | 5 ++--- vscode/src/editor/utils/editor-context.test.ts | 7 +------ vscode/src/editor/utils/editor-context.ts | 2 -- 7 files changed, 8 insertions(+), 17 deletions(-) diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/ContextItem.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/ContextItem.kt index 30eea04c5389..01987e46c8f2 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/ContextItem.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/ContextItem.kt @@ -31,7 +31,6 @@ data class ContextItemFile( val source: ContextItemSource? = null, // Oneof: embeddings, user, keyword, editor, filename, search, unified, selection, terminal, uri val type: TypeEnum? = null, // Oneof: file val isTooLarge: Boolean? = null, - val size: Int? = null, ) : ContextItem() { enum class TypeEnum { diff --git a/lib/shared/src/codebase-context/messages.ts b/lib/shared/src/codebase-context/messages.ts index 6c2b182e309a..cdf291c64e86 100644 --- a/lib/shared/src/codebase-context/messages.ts +++ b/lib/shared/src/codebase-context/messages.ts @@ -87,8 +87,6 @@ export interface ContextItemFile extends ContextItemCommon { * Whether the file is too large to be included as context. */ isTooLarge?: boolean - - size?: number } /** diff --git a/vscode/src/chat/chat-view/SimpleChatModel.ts b/vscode/src/chat/chat-view/SimpleChatModel.ts index 17a3a2f4e271..99d47141d33e 100644 --- a/vscode/src/chat/chat-view/SimpleChatModel.ts +++ b/vscode/src/chat/chat-view/SimpleChatModel.ts @@ -16,6 +16,7 @@ import type { Repo } from '../../context/repo-fetcher' import { getChatPanelTitle } from './chat-helpers' export class SimpleChatModel { + // The maximum number of characters in the context window for the current model. public readonly maxChars: number constructor( public modelID: string, diff --git a/vscode/src/chat/context/chatContext.ts b/vscode/src/chat/context/chatContext.ts index a09d8c5ccec7..10dd0c4c2a74 100644 --- a/vscode/src/chat/context/chatContext.ts +++ b/vscode/src/chat/context/chatContext.ts @@ -20,7 +20,8 @@ export async function getChatContextItemsForMention( empty: () => void withType: (type: MentionQuery['type']) => void }, - charsLimit?: number + // The number of characters left in current context window. + maxChars?: number ): Promise { const mentionQuery = parseMentionQuery(query) @@ -36,12 +37,12 @@ export async function getChatContextItemsForMention( const MAX_RESULTS = 20 switch (mentionQuery.type) { case 'empty': - return getOpenTabsContextFile(charsLimit) + return getOpenTabsContextFile(maxChars) case 'symbol': // It would be nice if the VS Code symbols API supports cancellation, but it doesn't return getSymbolContextFiles(mentionQuery.text, MAX_RESULTS) case 'file': - return getFileContextFiles(mentionQuery.text, MAX_RESULTS, cancellationToken, charsLimit) + return getFileContextFiles(mentionQuery.text, MAX_RESULTS, maxChars) case 'url': return (await isURLContextFeatureFlagEnabled()) ? getURLContextItems( diff --git a/vscode/src/edit/input/get-matching-context.ts b/vscode/src/edit/input/get-matching-context.ts index 67830fddb075..1fbbe62cd384 100644 --- a/vscode/src/edit/input/get-matching-context.ts +++ b/vscode/src/edit/input/get-matching-context.ts @@ -1,6 +1,6 @@ import type { ContextItem, MentionQuery } from '@sourcegraph/cody-shared' -import * as vscode from 'vscode' +import { DEFAULT_FAST_MODEL_CHARS_LIMIT } from '@sourcegraph/cody-shared/src/prompt/constants' import { getFileContextFiles, getSymbolContextFiles } from '../../editor/utils/editor-context' import { getLabelForContextItem } from './utils' @@ -29,11 +29,10 @@ export async function getMatchingContext( } if (mentionQuery.type === 'file') { - const cancellation = new vscode.CancellationTokenSource() const fileResults = await getFileContextFiles( mentionQuery.text, MAX_FUZZY_RESULTS, - cancellation.token + DEFAULT_FAST_MODEL_CHARS_LIMIT ) return fileResults.map(result => ({ key: getLabelForContextItem(result), diff --git a/vscode/src/editor/utils/editor-context.test.ts b/vscode/src/editor/utils/editor-context.test.ts index 360d95c8cc4d..87518cbfa045 100644 --- a/vscode/src/editor/utils/editor-context.test.ts +++ b/vscode/src/editor/utils/editor-context.test.ts @@ -41,11 +41,7 @@ describe('getFileContextFiles', () => { } async function runSearch(query: string, maxResults: number): Promise<(string | undefined)[]> { - const results = await getFileContextFiles( - query, - maxResults, - new vscode.CancellationTokenSource().token - ) + const results = await getFileContextFiles(query, maxResults) return results.map(f => uriBasename(f.uri)) } @@ -175,7 +171,6 @@ describe('filterLargeFiles', () => { type: 'file', uri: largeTextFile.uri, isTooLarge: true, - size: oneByteOverTokenLimit, }) }) }) diff --git a/vscode/src/editor/utils/editor-context.ts b/vscode/src/editor/utils/editor-context.ts index b7cf2ca7bad8..f3bbc396527e 100644 --- a/vscode/src/editor/utils/editor-context.ts +++ b/vscode/src/editor/utils/editor-context.ts @@ -56,7 +56,6 @@ const throttledFindFiles = throttle(() => findWorkspaceFiles(), 10000) export async function getFileContextFiles( query: string, maxResults: number, - cancellationToken: vscode.CancellationToken, charsLimit?: number ): Promise { if (!query.trim()) { @@ -273,7 +272,6 @@ export async function filterLargeFiles( // Check if file contains more characters than the token limit based on fileStat.size // and set {@link ContextItemFile.isTooLarge} for webview to display file size // warning. - cf.size = fileStat.size if (fileStat.size > charsLimit) { cf.isTooLarge = true } From 23d831150904f53f17d463c6943068280286d096 Mon Sep 17 00:00:00 2001 From: Beatrix Date: Tue, 26 Mar 2024 13:37:34 -0700 Subject: [PATCH 5/5] clean up and add changelog entry --- vscode/CHANGELOG.md | 1 + vscode/src/chat/chat-view/SimpleChatModel.ts | 13 +------ .../chat/chat-view/SimpleChatPanelProvider.ts | 13 +++++-- vscode/src/chat/utils.ts | 34 ++++++++++++++++++- 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/vscode/CHANGELOG.md b/vscode/CHANGELOG.md index f21c49546776..a8b103d3dc00 100644 --- a/vscode/CHANGELOG.md +++ b/vscode/CHANGELOG.md @@ -15,6 +15,7 @@ This is a log of all notable changes to Cody for VS Code. [Unreleased] changes a - Chat: Large file cannot be added via @-mention. [pull/3531](https://github.com/sourcegraph/cody/pull/3531) - Chat: Handle empty chat message input and prevent submission of empty messages. [pull/3554](https://github.com/sourcegraph/cody/pull/3554) +- Chat: Warnings are now displayed correctly for large files in the @-mention file selection list. [pull/3526](https://github.com/sourcegraph/cody/pull/3526) ### Changed diff --git a/vscode/src/chat/chat-view/SimpleChatModel.ts b/vscode/src/chat/chat-view/SimpleChatModel.ts index 99d47141d33e..2d11bf61d03a 100644 --- a/vscode/src/chat/chat-view/SimpleChatModel.ts +++ b/vscode/src/chat/chat-view/SimpleChatModel.ts @@ -16,7 +16,7 @@ import type { Repo } from '../../context/repo-fetcher' import { getChatPanelTitle } from './chat-helpers' export class SimpleChatModel { - // The maximum number of characters in the context window for the current model. + // The maximum number of characters available in the model's context window. public readonly maxChars: number constructor( public modelID: string, @@ -28,17 +28,6 @@ export class SimpleChatModel { this.maxChars = ModelProvider.getMaxCharsByModel(this.modelID) } - public get charsLeft(): number { - let used = 0 - for (const msg of this.messages) { - if (used > this.maxChars) { - return 0 - } - used += msg.speaker.length + (msg.text?.length || 0) + 3 - } - return this.maxChars - used - } - public isEmpty(): boolean { return this.messages.length === 0 } diff --git a/vscode/src/chat/chat-view/SimpleChatPanelProvider.ts b/vscode/src/chat/chat-view/SimpleChatPanelProvider.ts index cf7648f36e9a..6e6e40b5577a 100644 --- a/vscode/src/chat/chat-view/SimpleChatPanelProvider.ts +++ b/vscode/src/chat/chat-view/SimpleChatPanelProvider.ts @@ -46,13 +46,13 @@ import { } from '../../services/utils/codeblock-action-tracker' import { openExternalLinks, openLocalFileWithRange } from '../../services/utils/workspace-action' import { TestSupport } from '../../test-support' -import { countGeneratedCode } from '../utils' +import { countGeneratedCode, getContextWindowLimitInBytes } from '../utils' import type { Span } from '@opentelemetry/api' import { captureException } from '@sentry/core' import type { ContextItemWithContent } from '@sourcegraph/cody-shared/src/codebase-context/messages' import { ModelUsage } from '@sourcegraph/cody-shared/src/models/types' -import { ANSWER_TOKENS } from '@sourcegraph/cody-shared/src/prompt/constants' +import { ANSWER_TOKENS, tokensToChars } from '@sourcegraph/cody-shared/src/prompt/constants' import { recordErrorToSpan, tracer } from '@sourcegraph/cody-shared/src/tracing' import type { EnterpriseContextFactory } from '../../context/enterprise-context-factory' import type { Repo } from '../../context/repo-fetcher' @@ -597,13 +597,20 @@ export class SimpleChatPanelProvider implements vscode.Disposable, ChatSession { }) }, } + // Use the number of characters left in the chat model as the limit + // for adding user context files to the chat. + const contextLimit = getContextWindowLimitInBytes( + [...this.chatModel.getMessages()], + // Minus the character limit reserved for the answer token + this.chatModel.maxChars - tokensToChars(ANSWER_TOKENS) + ) try { const items = await getChatContextItemsForMention( query, cancellation.token, scopedTelemetryRecorder, - this.chatModel.charsLeft - ANSWER_TOKENS + contextLimit ) if (cancellation.token.isCancellationRequested) { return diff --git a/vscode/src/chat/utils.ts b/vscode/src/chat/utils.ts index e5c0b954871c..1a785ea1116f 100644 --- a/vscode/src/chat/utils.ts +++ b/vscode/src/chat/utils.ts @@ -1,4 +1,4 @@ -import type { AuthStatus } from '@sourcegraph/cody-shared' +import type { AuthStatus, ChatMessage } from '@sourcegraph/cody-shared' import { defaultAuthStatus, unauthenticatedStatus } from './protocol' /** @@ -82,3 +82,35 @@ export const countGeneratedCode = (text: string): { lineCount: number; charCount } return count } + +/** + * Counts the total number of bytes used in a list of chat messages. + * + * This function is exported and can be used to calculate the byte usage + * of chat messages for storage/bandwidth purposes. + * + * @param messages - The list of chat messages to count bytes for + * @returns The total number of bytes used in the messages + */ +export function countBytesInChatMessages(messages: ChatMessage[]): number { + if (messages.length === 0) { + return 0 + } + return messages.reduce((acc, msg) => acc + msg.speaker.length + (msg.text?.length || 0) + 3, 0) +} + +/** + * Gets the context window limit in bytes for chat messages, taking into + * account the maximum allowed character count. Returns 0 if the used bytes + * exceeds the limit. + * @param messages - The chat messages + * @param maxChars - The maximum allowed character count + * @returns The context window limit in bytes + */ +export function getContextWindowLimitInBytes(messages: ChatMessage[], maxChars: number): number { + const used = countBytesInChatMessages(messages) + if (used > maxChars) { + return 0 + } + return maxChars - used +}