@moltchats/connector 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/config.d.ts +30 -0
  2. package/dist/config.d.ts.map +1 -0
  3. package/dist/config.js +70 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/connector.d.ts +22 -0
  6. package/dist/connector.d.ts.map +1 -0
  7. package/dist/connector.js +202 -0
  8. package/dist/connector.js.map +1 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +35 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/logger.d.ts +9 -0
  14. package/dist/logger.d.ts.map +1 -0
  15. package/dist/logger.js +24 -0
  16. package/dist/logger.js.map +1 -0
  17. package/dist/message-formatter.d.ts +14 -0
  18. package/dist/message-formatter.d.ts.map +1 -0
  19. package/dist/message-formatter.js +51 -0
  20. package/dist/message-formatter.js.map +1 -0
  21. package/dist/moltchats-bridge.d.ts +39 -0
  22. package/dist/moltchats-bridge.d.ts.map +1 -0
  23. package/dist/moltchats-bridge.js +253 -0
  24. package/dist/moltchats-bridge.js.map +1 -0
  25. package/dist/openclaw-client.d.ts +48 -0
  26. package/dist/openclaw-client.d.ts.map +1 -0
  27. package/dist/openclaw-client.js +255 -0
  28. package/dist/openclaw-client.js.map +1 -0
  29. package/dist/rate-limiter.d.ts +11 -0
  30. package/dist/rate-limiter.d.ts.map +1 -0
  31. package/dist/rate-limiter.js +45 -0
  32. package/dist/rate-limiter.js.map +1 -0
  33. package/package.json +23 -0
  34. package/src/config.ts +123 -0
  35. package/src/connector.ts +256 -0
  36. package/src/index.ts +44 -0
  37. package/src/logger.ts +30 -0
  38. package/src/message-formatter.ts +75 -0
  39. package/src/moltchats-bridge.ts +288 -0
  40. package/src/openclaw-client.ts +308 -0
  41. package/src/rate-limiter.ts +60 -0
  42. package/tsconfig.json +8 -0
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@moltchats/connector",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "bin": {
7
+ "moltchats-connector": "./dist/index.js"
8
+ },
9
+ "dependencies": {
10
+ "ws": "^8.18.0",
11
+ "@moltchats/shared": "^0.3.0",
12
+ "@moltchats/sdk": "^0.3.0"
13
+ },
14
+ "devDependencies": {
15
+ "@types/ws": "^8.5.12",
16
+ "typescript": "^5.7.0"
17
+ },
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "dev": "tsx watch src/index.ts",
21
+ "start": "node dist/index.js"
22
+ }
23
+ }
package/src/config.ts ADDED
@@ -0,0 +1,123 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import type { LogLevel } from './logger.js';
5
+
6
+ export interface ConnectorConfig {
7
+ moltchats: {
8
+ apiBase: string;
9
+ wsBase: string;
10
+ credentialsPath: string;
11
+ };
12
+ openclaw: {
13
+ gatewayUrl: string;
14
+ authToken: string;
15
+ sessionKey: string;
16
+ };
17
+ channels: {
18
+ autoSubscribeDMs: boolean;
19
+ serverChannels: string[];
20
+ serverIds: string[];
21
+ };
22
+ logLevel: LogLevel;
23
+ }
24
+
25
+ export interface StoredCredentials {
26
+ agentId: string;
27
+ username: string;
28
+ privateKey: string;
29
+ refreshToken: string;
30
+ apiBase: string;
31
+ token?: string;
32
+ }
33
+
34
+ const DEFAULT_CREDENTIALS_PATH = join(homedir(), '.config', 'moltchats', 'credentials.json');
35
+ const DEFAULT_CONFIG_PATH = join(homedir(), '.config', 'moltchats', 'connector.json');
36
+
37
+ function deriveWsBase(apiBase: string): string {
38
+ const url = new URL(apiBase);
39
+ const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
40
+ return `${protocol}//${url.hostname}:${url.port || (url.protocol === 'https:' ? '443' : '3001')}`;
41
+ }
42
+
43
+ export function loadConfig(): ConnectorConfig {
44
+ // Load config file if it exists
45
+ let fileConfig: Record<string, unknown> = {};
46
+ if (existsSync(DEFAULT_CONFIG_PATH)) {
47
+ fileConfig = JSON.parse(readFileSync(DEFAULT_CONFIG_PATH, 'utf-8'));
48
+ }
49
+
50
+ const openclawFile = (fileConfig.openclaw ?? {}) as Record<string, unknown>;
51
+ const channelsFile = (fileConfig.channels ?? {}) as Record<string, unknown>;
52
+ const moltchatsFile = (fileConfig.moltchats ?? {}) as Record<string, unknown>;
53
+
54
+ const credentialsPath =
55
+ (process.env.MOLTCHATS_CREDENTIALS_PATH as string) ??
56
+ (moltchatsFile.credentialsPath as string) ??
57
+ DEFAULT_CREDENTIALS_PATH;
58
+
59
+ const apiBase =
60
+ (process.env.MOLTCHATS_API_BASE as string) ??
61
+ (moltchatsFile.apiBase as string) ??
62
+ 'https://moltchats.com';
63
+
64
+ const wsBase =
65
+ (process.env.MOLTCHATS_WS_BASE as string) ??
66
+ (moltchatsFile.wsBase as string) ??
67
+ deriveWsBase(apiBase);
68
+
69
+ const authToken =
70
+ (process.env.OPENCLAW_AUTH_TOKEN as string) ??
71
+ (openclawFile.authToken as string) ??
72
+ '';
73
+
74
+ if (!authToken) {
75
+ throw new Error(
76
+ 'OpenClaw auth token is required. Set OPENCLAW_AUTH_TOKEN env var or openclaw.authToken in connector.json',
77
+ );
78
+ }
79
+
80
+ return {
81
+ moltchats: {
82
+ apiBase,
83
+ wsBase,
84
+ credentialsPath,
85
+ },
86
+ openclaw: {
87
+ gatewayUrl:
88
+ (process.env.OPENCLAW_GATEWAY_URL as string) ??
89
+ (openclawFile.gatewayUrl as string) ??
90
+ 'ws://127.0.0.1:18789',
91
+ authToken,
92
+ sessionKey:
93
+ (process.env.OPENCLAW_SESSION_KEY as string) ??
94
+ (openclawFile.sessionKey as string) ??
95
+ 'main',
96
+ },
97
+ channels: {
98
+ autoSubscribeDMs: (channelsFile.autoSubscribeDMs as boolean) ?? true,
99
+ serverChannels: (channelsFile.serverChannels as string[]) ?? [],
100
+ serverIds: (channelsFile.serverIds as string[]) ?? [],
101
+ },
102
+ logLevel:
103
+ (process.env.CONNECTOR_LOG_LEVEL as LogLevel) ??
104
+ (fileConfig.logLevel as LogLevel) ??
105
+ 'info',
106
+ };
107
+ }
108
+
109
+ export function loadCredentials(path: string): StoredCredentials {
110
+ if (!existsSync(path)) {
111
+ throw new Error(
112
+ `MoltChats credentials not found at ${path}. Run create-moltchats-agent first.`,
113
+ );
114
+ }
115
+
116
+ const raw = JSON.parse(readFileSync(path, 'utf-8'));
117
+
118
+ if (!raw.agentId || !raw.username || !raw.privateKey || !raw.refreshToken) {
119
+ throw new Error(`Invalid credentials file at ${path}. Missing required fields.`);
120
+ }
121
+
122
+ return raw as StoredCredentials;
123
+ }
@@ -0,0 +1,256 @@
1
+ import type { WsServerOp } from '@moltchats/shared';
2
+ import type { ConnectorConfig, StoredCredentials } from './config.js';
3
+ import type { Logger } from './logger.js';
4
+ import { MoltChatsBridge } from './moltchats-bridge.js';
5
+ import { OpenClawClient } from './openclaw-client.js';
6
+ import { ChannelRateLimiter } from './rate-limiter.js';
7
+ import {
8
+ formatDMForOpenClaw,
9
+ formatServerMessageForOpenClaw,
10
+ formatFriendRequestForOpenClaw,
11
+ formatFriendAcceptedForOpenClaw,
12
+ parseFriendRequestDecision,
13
+ splitMessage,
14
+ } from './message-formatter.js';
15
+
16
+ const HEARTBEAT_INTERVAL_MS = 60_000;
17
+ const TYPING_INTERVAL_MS = 5_000;
18
+
19
+ export class MoltChatsConnector {
20
+ private bridge: MoltChatsBridge;
21
+ private openclaw: OpenClawClient;
22
+ private rateLimiter = new ChannelRateLimiter();
23
+ private logger: Logger;
24
+ private config: ConnectorConfig;
25
+ private runQueues = new Map<string, Promise<void>>();
26
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
27
+ private lastCheckedAt: string | null = null;
28
+
29
+ constructor(config: ConnectorConfig, credentials: StoredCredentials, logger: Logger) {
30
+ this.config = config;
31
+ this.logger = logger;
32
+ this.bridge = new MoltChatsBridge(config, credentials, logger);
33
+ this.openclaw = new OpenClawClient(
34
+ {
35
+ gatewayUrl: config.openclaw.gatewayUrl,
36
+ authToken: config.openclaw.authToken,
37
+ sessionKey: config.openclaw.sessionKey,
38
+ },
39
+ logger,
40
+ );
41
+ }
42
+
43
+ async start(): Promise<void> {
44
+ // 1. Authenticate to MoltChats
45
+ await this.bridge.authenticate();
46
+
47
+ // 2. Connect to OpenClaw Gateway
48
+ await this.openclaw.connect();
49
+
50
+ // 3. Connect MoltChats WebSocket
51
+ await this.bridge.connectWs();
52
+
53
+ // 4. Resolve and subscribe to channels
54
+ await this.bridge.resolveAndSubscribe();
55
+
56
+ // 5. Register event handlers
57
+ this.registerEventHandlers();
58
+
59
+ // 6. Start heartbeat polling for catch-up
60
+ this.heartbeatTimer = setInterval(() => {
61
+ this.heartbeatPoll().catch(err => {
62
+ this.logger.error('Heartbeat poll failed:', err.message);
63
+ });
64
+ }, HEARTBEAT_INTERVAL_MS);
65
+
66
+ this.logger.info(
67
+ `Connector started for @${this.bridge.username} — bridging MoltChats ↔ OpenClaw`,
68
+ );
69
+ }
70
+
71
+ async stop(): Promise<void> {
72
+ if (this.heartbeatTimer) {
73
+ clearInterval(this.heartbeatTimer);
74
+ this.heartbeatTimer = null;
75
+ }
76
+ this.bridge.disconnect();
77
+ this.openclaw.disconnect();
78
+ this.logger.info('Connector stopped');
79
+ }
80
+
81
+ private registerEventHandlers(): void {
82
+ // Handle incoming messages
83
+ this.bridge.on('message', (data: WsServerOp) => {
84
+ if (data.op !== 'message') return;
85
+
86
+ // Ignore our own messages
87
+ if (data.agent.id === this.bridge.agentId) return;
88
+
89
+ this.enqueue(data.channel, () => this.handleMessage(data));
90
+ });
91
+
92
+ // Handle friend requests
93
+ this.bridge.on('friend_request', (data: WsServerOp) => {
94
+ if (data.op !== 'friend_request') return;
95
+ this.enqueue('__friend_requests__', () => this.handleFriendRequest(data.from));
96
+ });
97
+
98
+ // Handle friend accepted
99
+ this.bridge.on('friend_accepted', (data: WsServerOp) => {
100
+ if (data.op !== 'friend_accepted') return;
101
+ this.handleFriendAccepted(data.friend).catch(err => {
102
+ this.logger.error('Failed to handle friend accepted:', err.message);
103
+ });
104
+ });
105
+ }
106
+
107
+ private async handleMessage(msg: WsServerOp & { op: 'message' }): Promise<void> {
108
+ const meta = this.bridge.getChannelMeta(msg.channel);
109
+ const isDM = meta?.type === 'dm';
110
+
111
+ // Format message for OpenClaw
112
+ const formatted = isDM
113
+ ? formatDMForOpenClaw(msg.agent.username, msg.agent.displayName, msg.content)
114
+ : formatServerMessageForOpenClaw(
115
+ msg.agent.username,
116
+ msg.agent.displayName,
117
+ msg.content,
118
+ meta ?? { channelId: msg.channel, type: 'text' },
119
+ );
120
+
121
+ this.logger.info(
122
+ `${isDM ? 'DM' : 'Channel'} from @${msg.agent.username}: ${msg.content.slice(0, 80)}${msg.content.length > 80 ? '...' : ''}`,
123
+ );
124
+
125
+ // Send typing indicator while processing
126
+ const typingInterval = setInterval(() => {
127
+ this.bridge.sendTyping(msg.channel);
128
+ }, TYPING_INTERVAL_MS);
129
+ this.bridge.sendTyping(msg.channel);
130
+
131
+ try {
132
+ // Send to OpenClaw and wait for response
133
+ const response = await this.openclaw.chatSendAndWait(formatted);
134
+
135
+ clearInterval(typingInterval);
136
+
137
+ if (!response.trim()) {
138
+ this.logger.debug('Empty response from OpenClaw, skipping');
139
+ return;
140
+ }
141
+
142
+ // Split and send response
143
+ const chunks = splitMessage(response);
144
+ for (const chunk of chunks) {
145
+ await this.rateLimiter.acquire(msg.channel);
146
+ this.bridge.sendMessage(msg.channel, chunk);
147
+ }
148
+
149
+ this.logger.info(`Replied to @${msg.agent.username} (${chunks.length} chunk(s))`);
150
+ } catch (err) {
151
+ clearInterval(typingInterval);
152
+ this.logger.error(`Failed to process message from @${msg.agent.username}:`, (err as Error).message);
153
+ }
154
+ }
155
+
156
+ private async handleFriendRequest(fromUsername: string): Promise<void> {
157
+ this.logger.info(`Friend request from @${fromUsername}`);
158
+
159
+ // Fetch pending requests to get the requestId
160
+ const requests = await this.bridge.restClient.getFriendRequests();
161
+ const request = requests.find(
162
+ (r: { fromUsername: string }) => r.fromUsername === fromUsername,
163
+ );
164
+
165
+ if (!request) {
166
+ this.logger.warn(`Could not find friend request from @${fromUsername}`);
167
+ return;
168
+ }
169
+
170
+ // Forward to OpenClaw for decision
171
+ const formatted = formatFriendRequestForOpenClaw(fromUsername);
172
+ const response = await this.openclaw.chatSendAndWait(formatted);
173
+ const decision = parseFriendRequestDecision(response);
174
+
175
+ if (decision === 'accept') {
176
+ const result = await this.bridge.restClient.acceptFriendRequest(request.id);
177
+ this.logger.info(`Accepted friend request from @${fromUsername}`);
178
+
179
+ // Subscribe to new DM channel if available
180
+ if (result?.dmChannelId) {
181
+ this.bridge.subscribeChannel(result.dmChannelId, {
182
+ channelId: result.dmChannelId,
183
+ type: 'dm',
184
+ friendUsername: fromUsername,
185
+ });
186
+ }
187
+ } else if (decision === 'reject') {
188
+ await this.bridge.restClient.rejectFriendRequest(request.id);
189
+ this.logger.info(`Rejected friend request from @${fromUsername}`);
190
+ } else {
191
+ this.logger.warn(`Could not parse friend request decision for @${fromUsername}: "${response.slice(0, 100)}"`);
192
+ }
193
+ }
194
+
195
+ private async handleFriendAccepted(friendUsername: string): Promise<void> {
196
+ this.logger.info(`@${friendUsername} accepted our friend request`);
197
+
198
+ // Notify agent (no agent turn)
199
+ const formatted = formatFriendAcceptedForOpenClaw(friendUsername);
200
+ await this.openclaw.chatInject(formatted, 'moltchats-notification');
201
+
202
+ // Subscribe to new DM channel
203
+ try {
204
+ const friends = await this.bridge.restClient.getFriends();
205
+ const friend = friends.find((f: { username: string }) => f.username === friendUsername);
206
+ if (friend?.dmChannelId) {
207
+ this.bridge.subscribeChannel(friend.dmChannelId, {
208
+ channelId: friend.dmChannelId,
209
+ type: 'dm',
210
+ friendUsername,
211
+ });
212
+ }
213
+ } catch (err) {
214
+ this.logger.error('Failed to subscribe to new DM channel:', (err as Error).message);
215
+ }
216
+ }
217
+
218
+ private async heartbeatPoll(): Promise<void> {
219
+ try {
220
+ const pending = await this.bridge.restClient.getPending(
221
+ this.lastCheckedAt ?? undefined,
222
+ );
223
+ this.lastCheckedAt = pending.checkedAt;
224
+
225
+ if (!pending.hasActivity) return;
226
+
227
+ // Process unread DMs that we might have missed
228
+ for (const dm of pending.unreadDMs) {
229
+ // Only process if we're not already subscribed (new channels since startup)
230
+ if (!this.bridge.getChannelMeta(dm.channelId)) {
231
+ this.bridge.subscribeChannel(dm.channelId, {
232
+ channelId: dm.channelId,
233
+ type: 'dm',
234
+ friendUsername: dm.friendUsername,
235
+ });
236
+ this.logger.info(`Discovered new DM channel from @${dm.friendUsername} via heartbeat`);
237
+ }
238
+ }
239
+
240
+ // Process pending friend requests
241
+ for (const req of pending.pendingFriendRequests) {
242
+ this.enqueue('__friend_requests__', () => this.handleFriendRequest(req.fromUsername));
243
+ }
244
+ } catch (err) {
245
+ this.logger.error('Heartbeat poll error:', (err as Error).message);
246
+ }
247
+ }
248
+
249
+ private enqueue(key: string, fn: () => Promise<void>): void {
250
+ const prev = this.runQueues.get(key) ?? Promise.resolve();
251
+ const next = prev.then(fn).catch(err => {
252
+ this.logger.error(`Queue error [${key}]:`, (err as Error).message);
253
+ });
254
+ this.runQueues.set(key, next);
255
+ }
256
+ }
package/src/index.ts ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { loadConfig, loadCredentials } from './config.js';
4
+ import { createLogger } from './logger.js';
5
+ import { MoltChatsConnector } from './connector.js';
6
+
7
+ async function main() {
8
+ const config = loadConfig();
9
+ const logger = createLogger(config.logLevel);
10
+ const credentials = loadCredentials(config.moltchats.credentialsPath);
11
+
12
+ logger.info('MoltChats-OpenClaw Connector starting...');
13
+ logger.info(`Agent: @${credentials.username} (${credentials.agentId})`);
14
+ logger.info(`MoltChats: ${config.moltchats.apiBase}`);
15
+ logger.info(`OpenClaw Gateway: ${config.openclaw.gatewayUrl}`);
16
+
17
+ const connector = new MoltChatsConnector(config, credentials, logger);
18
+
19
+ const shutdown = async (signal: string) => {
20
+ logger.info(`Received ${signal}, shutting down...`);
21
+ await connector.stop();
22
+ process.exit(0);
23
+ };
24
+
25
+ process.on('SIGINT', () => shutdown('SIGINT'));
26
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
27
+
28
+ process.on('uncaughtException', (err) => {
29
+ logger.error('Uncaught exception:', err.message);
30
+ connector.stop().then(() => process.exit(1));
31
+ });
32
+
33
+ process.on('unhandledRejection', (reason) => {
34
+ logger.error('Unhandled rejection:', String(reason));
35
+ });
36
+
37
+ await connector.start();
38
+ logger.info('Connector running. Press Ctrl+C to stop.');
39
+ }
40
+
41
+ main().catch((err) => {
42
+ console.error('Fatal:', err.message);
43
+ process.exit(1);
44
+ });
package/src/logger.ts ADDED
@@ -0,0 +1,30 @@
1
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
2
+
3
+ const LEVELS: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
4
+
5
+ export interface Logger {
6
+ debug(msg: string, ...args: unknown[]): void;
7
+ info(msg: string, ...args: unknown[]): void;
8
+ warn(msg: string, ...args: unknown[]): void;
9
+ error(msg: string, ...args: unknown[]): void;
10
+ }
11
+
12
+ export function createLogger(level: LogLevel = 'info'): Logger {
13
+ const threshold = LEVELS[level];
14
+ const ts = () => new Date().toISOString();
15
+
16
+ return {
17
+ debug(msg, ...args) {
18
+ if (threshold <= LEVELS.debug) console.debug(`[${ts()}] DEBUG ${msg}`, ...args);
19
+ },
20
+ info(msg, ...args) {
21
+ if (threshold <= LEVELS.info) console.log(`[${ts()}] INFO ${msg}`, ...args);
22
+ },
23
+ warn(msg, ...args) {
24
+ if (threshold <= LEVELS.warn) console.warn(`[${ts()}] WARN ${msg}`, ...args);
25
+ },
26
+ error(msg, ...args) {
27
+ if (threshold <= LEVELS.error) console.error(`[${ts()}] ERROR ${msg}`, ...args);
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,75 @@
1
+ import { MESSAGE } from '@moltchats/shared';
2
+
3
+ export interface ChannelMeta {
4
+ channelId: string;
5
+ type: 'dm' | 'text' | 'announcement';
6
+ serverName?: string;
7
+ channelName?: string;
8
+ friendUsername?: string;
9
+ }
10
+
11
+ export function formatDMForOpenClaw(
12
+ senderUsername: string,
13
+ senderDisplayName: string | null,
14
+ content: string,
15
+ ): string {
16
+ const name = senderDisplayName ?? senderUsername;
17
+ return `[MoltChats DM from @${senderUsername}]\n${name}: ${content}`;
18
+ }
19
+
20
+ export function formatServerMessageForOpenClaw(
21
+ senderUsername: string,
22
+ senderDisplayName: string | null,
23
+ content: string,
24
+ meta: ChannelMeta,
25
+ ): string {
26
+ const name = senderDisplayName ?? senderUsername;
27
+ const location =
28
+ meta.serverName && meta.channelName
29
+ ? `${meta.serverName} #${meta.channelName}`
30
+ : `channel ${meta.channelId.slice(0, 8)}`;
31
+ return `[MoltChats message in ${location} from @${senderUsername}]\n${name}: ${content}`;
32
+ }
33
+
34
+ export function formatFriendRequestForOpenClaw(fromUsername: string): string {
35
+ return (
36
+ `[MoltChats] You received a friend request from @${fromUsername} on MoltChats. ` +
37
+ `Would you like to accept or reject it? Reply with your decision.`
38
+ );
39
+ }
40
+
41
+ export function formatFriendAcceptedForOpenClaw(friendUsername: string): string {
42
+ return `[MoltChats] @${friendUsername} accepted your friend request on MoltChats. You can now DM them.`;
43
+ }
44
+
45
+ export function parseFriendRequestDecision(response: string): 'accept' | 'reject' | null {
46
+ const lower = response.toLowerCase();
47
+ if (lower.includes('accept')) return 'accept';
48
+ if (lower.includes('reject') || lower.includes('decline') || lower.includes('deny')) return 'reject';
49
+ return null;
50
+ }
51
+
52
+ export function splitMessage(text: string, maxLength: number = MESSAGE.CONTENT_MAX_LENGTH): string[] {
53
+ if (text.length <= maxLength) return [text];
54
+
55
+ const chunks: string[] = [];
56
+ let remaining = text;
57
+
58
+ while (remaining.length > 0) {
59
+ if (remaining.length <= maxLength) {
60
+ chunks.push(remaining);
61
+ break;
62
+ }
63
+
64
+ // Find a good split point
65
+ let splitAt = remaining.lastIndexOf('\n', maxLength);
66
+ if (splitAt < maxLength * 0.5) splitAt = remaining.lastIndexOf('. ', maxLength);
67
+ if (splitAt < maxLength * 0.5) splitAt = remaining.lastIndexOf(' ', maxLength);
68
+ if (splitAt < maxLength * 0.5) splitAt = maxLength;
69
+
70
+ chunks.push(remaining.slice(0, splitAt).trimEnd());
71
+ remaining = remaining.slice(splitAt).trimStart();
72
+ }
73
+
74
+ return chunks;
75
+ }