ai-cli-mcp 2.11.0 → 2.13.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 (55) hide show
  1. package/.github/workflows/publish.yml +25 -0
  2. package/CHANGELOG.md +23 -0
  3. package/README.ja.md +112 -8
  4. package/README.md +112 -9
  5. package/dist/__tests__/app-cli.test.js +293 -0
  6. package/dist/__tests__/cli-bin-smoke.test.js +58 -0
  7. package/dist/__tests__/cli-builder.test.js +37 -0
  8. package/dist/__tests__/cli-process-service.test.js +279 -0
  9. package/dist/__tests__/cli-utils.test.js +140 -0
  10. package/dist/__tests__/error-cases.test.js +2 -1
  11. package/dist/__tests__/mcp-contract.test.js +343 -0
  12. package/dist/__tests__/parsers.test.js +37 -1
  13. package/dist/__tests__/process-management.test.js +15 -8
  14. package/dist/__tests__/server.test.js +29 -3
  15. package/dist/__tests__/wait.test.js +31 -0
  16. package/dist/app/cli.js +304 -0
  17. package/dist/app/mcp.js +366 -0
  18. package/dist/bin/ai-cli-mcp.js +6 -0
  19. package/dist/bin/ai-cli.js +10 -0
  20. package/dist/cli-builder.js +15 -6
  21. package/dist/cli-parse.js +8 -5
  22. package/dist/cli-process-service.js +332 -0
  23. package/dist/cli-utils.js +159 -88
  24. package/dist/cli.js +4 -3
  25. package/dist/model-catalog.js +53 -0
  26. package/dist/parsers.js +55 -0
  27. package/dist/process-service.js +201 -0
  28. package/dist/server.js +4 -578
  29. package/docs/cli-architecture.md +275 -0
  30. package/package.json +4 -3
  31. package/server.json +1 -1
  32. package/src/__tests__/app-cli.test.ts +370 -0
  33. package/src/__tests__/cli-bin-smoke.test.ts +75 -0
  34. package/src/__tests__/cli-builder.test.ts +47 -0
  35. package/src/__tests__/cli-process-service.test.ts +334 -0
  36. package/src/__tests__/cli-utils.test.ts +166 -0
  37. package/src/__tests__/error-cases.test.ts +3 -4
  38. package/src/__tests__/mcp-contract.test.ts +422 -0
  39. package/src/__tests__/parsers.test.ts +44 -1
  40. package/src/__tests__/process-management.test.ts +15 -9
  41. package/src/__tests__/server.test.ts +27 -6
  42. package/src/__tests__/wait.test.ts +38 -0
  43. package/src/app/cli.ts +373 -0
  44. package/src/app/mcp.ts +402 -0
  45. package/src/bin/ai-cli-mcp.ts +7 -0
  46. package/src/bin/ai-cli.ts +11 -0
  47. package/src/cli-builder.ts +19 -10
  48. package/src/cli-parse.ts +8 -5
  49. package/src/cli-process-service.ts +418 -0
  50. package/src/cli-utils.ts +205 -99
  51. package/src/cli.ts +4 -3
  52. package/src/model-catalog.ts +64 -0
  53. package/src/parsers.ts +61 -0
  54. package/src/process-service.ts +263 -0
  55. package/src/server.ts +4 -668
package/src/cli-utils.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync } from 'node:fs';
1
+ import { accessSync, constants } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import * as path from 'path';
@@ -13,90 +13,229 @@ export function debugLog(message?: any, ...optionalParams: any[]): void {
13
13
  }
14
14
  }
15
15
 
16
- /**
17
- * Determine the Gemini CLI command/path.
18
- * Similar to findClaudeCli but for Gemini
19
- */
20
- export function findGeminiCli(): string {
21
- debugLog('[Debug] Attempting to find Gemini CLI...');
22
-
23
- // Check for custom CLI name from environment variable
24
- const customCliName = process.env.GEMINI_CLI_NAME;
25
- if (customCliName) {
26
- debugLog(`[Debug] Using custom Gemini CLI name from GEMINI_CLI_NAME: ${customCliName}`);
16
+ export interface CliBinaryStatus {
17
+ configuredCommand: string;
18
+ resolvedPath: string | null;
19
+ available: boolean;
20
+ lookup: 'env' | 'local' | 'path';
21
+ error?: string;
22
+ }
27
23
 
28
- // If it's an absolute path, use it directly
29
- if (path.isAbsolute(customCliName)) {
30
- debugLog(`[Debug] GEMINI_CLI_NAME is an absolute path: ${customCliName}`);
31
- return customCliName;
32
- }
24
+ function getPathDelimiter(): string {
25
+ return process.platform === 'win32' ? ';' : ':';
26
+ }
33
27
 
34
- // If it starts with ~ or ./, reject as relative paths are not allowed
35
- if (customCliName.startsWith('./') || customCliName.startsWith('../') || customCliName.includes('/')) {
36
- throw new Error(`Invalid GEMINI_CLI_NAME: Relative paths are not allowed. Use either a simple name (e.g., 'gemini') or an absolute path (e.g., '/tmp/gemini-test')`);
37
- }
28
+ function getPathExtensions(): string[] {
29
+ if (process.platform !== 'win32') {
30
+ return [''];
38
31
  }
39
32
 
40
- const cliName = customCliName || 'gemini';
33
+ const rawPathext = process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM';
34
+ return ['', ...rawPathext.split(';').filter(Boolean)];
35
+ }
41
36
 
42
- // Try local install path: ~/.gemini/local/gemini
43
- const userPath = join(homedir(), '.gemini', 'local', 'gemini');
44
- debugLog(`[Debug] Checking for Gemini CLI at local user path: ${userPath}`);
37
+ function findExecutableOnPath(commandName: string): string | null {
38
+ const rawPath = process.env.PATH || '';
39
+ if (!rawPath) {
40
+ return null;
41
+ }
45
42
 
46
- if (existsSync(userPath)) {
47
- debugLog(`[Debug] Found Gemini CLI at local user path: ${userPath}. Using this path.`);
48
- return userPath;
49
- } else {
50
- debugLog(`[Debug] Gemini CLI not found at local user path: ${userPath}.`);
43
+ const pathEntries = rawPath.split(getPathDelimiter()).filter(Boolean);
44
+ const extensions = getPathExtensions();
45
+
46
+ for (const entry of pathEntries) {
47
+ for (const extension of extensions) {
48
+ const candidate = join(entry, `${commandName}${extension}`);
49
+ if (isExecutableFile(candidate)) {
50
+ return candidate;
51
+ }
52
+ }
51
53
  }
52
54
 
53
- // Fallback to CLI name (PATH lookup)
54
- debugLog(`[Debug] Falling back to "${cliName}" command name, relying on spawn/PATH lookup.`);
55
- console.warn(`[Warning] Gemini CLI not found at ~/.gemini/local/gemini. Falling back to "${cliName}" in PATH. Ensure it is installed and accessible.`);
56
- return cliName;
55
+ return null;
57
56
  }
58
57
 
59
- /**
60
- * Determine the Codex CLI command/path.
61
- * Similar to findClaudeCli but for Codex
62
- */
63
- export function findCodexCli(): string {
64
- debugLog('[Debug] Attempting to find Codex CLI...');
58
+ function validateCustomCliName(envVarName: string, customCliName: string): string | null {
59
+ if (path.isAbsolute(customCliName)) {
60
+ return null;
61
+ }
62
+
63
+ if (
64
+ customCliName.startsWith('./') ||
65
+ customCliName.startsWith('../') ||
66
+ customCliName.includes('/')
67
+ ) {
68
+ return `Invalid ${envVarName}: Relative paths are not allowed. Use either a simple name (e.g., '${customCliName.split('/').pop() || 'cli'}') or an absolute path (e.g., '/tmp/${customCliName.split('/').pop() || 'cli'}-test')`;
69
+ }
65
70
 
66
- // Check for custom CLI name from environment variable
67
- const customCliName = process.env.CODEX_CLI_NAME;
68
- if (customCliName) {
69
- debugLog(`[Debug] Using custom Codex CLI name from CODEX_CLI_NAME: ${customCliName}`);
71
+ return null;
72
+ }
70
73
 
71
- // If it's an absolute path, use it directly
72
- if (path.isAbsolute(customCliName)) {
73
- debugLog(`[Debug] CODEX_CLI_NAME is an absolute path: ${customCliName}`);
74
- return customCliName;
74
+ function inspectCliBinary(options: {
75
+ envVarName: string;
76
+ customCliName: string | undefined;
77
+ defaultCliName: string;
78
+ localInstallPath: string;
79
+ }): CliBinaryStatus {
80
+ const configuredCommand = options.customCliName || options.defaultCliName;
81
+
82
+ if (options.customCliName) {
83
+ const validationError = validateCustomCliName(options.envVarName, options.customCliName);
84
+ if (validationError) {
85
+ return {
86
+ configuredCommand,
87
+ resolvedPath: null,
88
+ available: false,
89
+ lookup: 'env',
90
+ error: validationError,
91
+ };
75
92
  }
76
93
 
77
- // If it starts with ~ or ./, reject as relative paths are not allowed
78
- if (customCliName.startsWith('./') || customCliName.startsWith('../') || customCliName.includes('/')) {
79
- throw new Error(`Invalid CODEX_CLI_NAME: Relative paths are not allowed. Use either a simple name (e.g., 'codex') or an absolute path (e.g., '/tmp/codex-test')`);
94
+ if (path.isAbsolute(options.customCliName)) {
95
+ return {
96
+ configuredCommand,
97
+ resolvedPath: options.customCliName,
98
+ available: isExecutableFile(options.customCliName),
99
+ lookup: 'env',
100
+ };
80
101
  }
102
+
103
+ const resolvedPath = findExecutableOnPath(configuredCommand);
104
+ return {
105
+ configuredCommand,
106
+ resolvedPath,
107
+ available: resolvedPath !== null,
108
+ lookup: 'env',
109
+ };
110
+ }
111
+
112
+ if (isExecutableFile(options.localInstallPath)) {
113
+ return {
114
+ configuredCommand,
115
+ resolvedPath: options.localInstallPath,
116
+ available: true,
117
+ lookup: 'local',
118
+ };
119
+ }
120
+
121
+ const resolvedPath = findExecutableOnPath(configuredCommand);
122
+ return {
123
+ configuredCommand,
124
+ resolvedPath,
125
+ available: resolvedPath !== null,
126
+ lookup: 'path',
127
+ };
128
+ }
129
+
130
+ function getCliCommandOrThrow(status: CliBinaryStatus): string {
131
+ if (status.error) {
132
+ throw new Error(status.error);
133
+ }
134
+
135
+ if (status.lookup === 'env' && !path.isAbsolute(status.configuredCommand)) {
136
+ return status.configuredCommand;
137
+ }
138
+
139
+ return status.resolvedPath || status.configuredCommand;
140
+ }
141
+
142
+ function isExecutableFile(filePath: string): boolean {
143
+ try {
144
+ accessSync(filePath, constants.X_OK);
145
+ return true;
146
+ } catch {
147
+ return false;
81
148
  }
149
+ }
82
150
 
83
- const cliName = customCliName || 'codex';
151
+ type CliBinaryName = 'claude' | 'codex' | 'gemini' | 'forge';
152
+
153
+ function getCliBinaryConfig(name: CliBinaryName): {
154
+ envVarName: string;
155
+ customCliName: string | undefined;
156
+ defaultCliName: string;
157
+ localInstallPath: string;
158
+ } {
159
+ if (name === 'claude') {
160
+ return {
161
+ envVarName: 'CLAUDE_CLI_NAME',
162
+ customCliName: process.env.CLAUDE_CLI_NAME,
163
+ defaultCliName: 'claude',
164
+ localInstallPath: join(homedir(), '.claude', 'local', 'claude'),
165
+ };
166
+ }
84
167
 
85
- // Try local install path: ~/.codex/local/codex
86
- const userPath = join(homedir(), '.codex', 'local', 'codex');
87
- debugLog(`[Debug] Checking for Codex CLI at local user path: ${userPath}`);
168
+ if (name === 'codex') {
169
+ return {
170
+ envVarName: 'CODEX_CLI_NAME',
171
+ customCliName: process.env.CODEX_CLI_NAME,
172
+ defaultCliName: 'codex',
173
+ localInstallPath: join(homedir(), '.codex', 'local', 'codex'),
174
+ };
175
+ }
88
176
 
89
- if (existsSync(userPath)) {
90
- debugLog(`[Debug] Found Codex CLI at local user path: ${userPath}. Using this path.`);
91
- return userPath;
92
- } else {
93
- debugLog(`[Debug] Codex CLI not found at local user path: ${userPath}.`);
177
+ if (name === 'forge') {
178
+ return {
179
+ envVarName: 'FORGE_CLI_NAME',
180
+ customCliName: process.env.FORGE_CLI_NAME,
181
+ defaultCliName: 'forge',
182
+ localInstallPath: join(homedir(), '.forge', 'local', 'forge'),
183
+ };
94
184
  }
95
185
 
96
- // Fallback to CLI name (PATH lookup)
97
- debugLog(`[Debug] Falling back to "${cliName}" command name, relying on spawn/PATH lookup.`);
98
- console.warn(`[Warning] Codex CLI not found at ~/.codex/local/codex. Falling back to "${cliName}" in PATH. Ensure it is installed and accessible.`);
99
- return cliName;
186
+ return {
187
+ envVarName: 'GEMINI_CLI_NAME',
188
+ customCliName: process.env.GEMINI_CLI_NAME,
189
+ defaultCliName: 'gemini',
190
+ localInstallPath: join(homedir(), '.gemini', 'local', 'gemini'),
191
+ };
192
+ }
193
+
194
+ function getCliBinaryStatus(name: CliBinaryName): CliBinaryStatus {
195
+ return inspectCliBinary(getCliBinaryConfig(name));
196
+ }
197
+
198
+ export function getCliDoctorStatus(): {
199
+ claude: CliBinaryStatus;
200
+ codex: CliBinaryStatus;
201
+ gemini: CliBinaryStatus;
202
+ forge: CliBinaryStatus;
203
+ } {
204
+ return {
205
+ claude: getCliBinaryStatus('claude'),
206
+ codex: getCliBinaryStatus('codex'),
207
+ gemini: getCliBinaryStatus('gemini'),
208
+ forge: getCliBinaryStatus('forge'),
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Determine the Gemini CLI command/path.
214
+ * Similar to findClaudeCli but for Gemini
215
+ */
216
+ export function findGeminiCli(): string {
217
+ debugLog('[Debug] Attempting to find Gemini CLI...');
218
+ const status = getCliBinaryStatus('gemini');
219
+ return getCliCommandOrThrow(status);
220
+ }
221
+
222
+ /**
223
+ * Determine the Codex CLI command/path.
224
+ * Similar to findClaudeCli but for Codex
225
+ */
226
+ export function findCodexCli(): string {
227
+ debugLog('[Debug] Attempting to find Codex CLI...');
228
+ const status = getCliBinaryStatus('codex');
229
+ return getCliCommandOrThrow(status);
230
+ }
231
+
232
+ /**
233
+ * Determine the Forge CLI command/path.
234
+ */
235
+ export function findForgeCli(): string {
236
+ debugLog('[Debug] Attempting to find Forge CLI...');
237
+ const status = getCliBinaryStatus('forge');
238
+ return getCliCommandOrThrow(status);
100
239
  }
101
240
 
102
241
  /**
@@ -110,39 +249,6 @@ export function findCodexCli(): string {
110
249
  */
111
250
  export function findClaudeCli(): string {
112
251
  debugLog('[Debug] Attempting to find Claude CLI...');
113
-
114
- // Check for custom CLI name from environment variable
115
- const customCliName = process.env.CLAUDE_CLI_NAME;
116
- if (customCliName) {
117
- debugLog(`[Debug] Using custom Claude CLI name from CLAUDE_CLI_NAME: ${customCliName}`);
118
-
119
- // If it's an absolute path, use it directly
120
- if (path.isAbsolute(customCliName)) {
121
- debugLog(`[Debug] CLAUDE_CLI_NAME is an absolute path: ${customCliName}`);
122
- return customCliName;
123
- }
124
-
125
- // If it starts with ~ or ./, reject as relative paths are not allowed
126
- if (customCliName.startsWith('./') || customCliName.startsWith('../') || customCliName.includes('/')) {
127
- throw new Error(`Invalid CLAUDE_CLI_NAME: Relative paths are not allowed. Use either a simple name (e.g., 'claude') or an absolute path (e.g., '/tmp/claude-test')`);
128
- }
129
- }
130
-
131
- const cliName = customCliName || 'claude';
132
-
133
- // Try local install path: ~/.claude/local/claude (using the original name for local installs)
134
- const userPath = join(homedir(), '.claude', 'local', 'claude');
135
- debugLog(`[Debug] Checking for Claude CLI at local user path: ${userPath}`);
136
-
137
- if (existsSync(userPath)) {
138
- debugLog(`[Debug] Found Claude CLI at local user path: ${userPath}. Using this path.`);
139
- return userPath;
140
- } else {
141
- debugLog(`[Debug] Claude CLI not found at local user path: ${userPath}.`);
142
- }
143
-
144
- // 3. Fallback to CLI name (PATH lookup)
145
- debugLog(`[Debug] Falling back to "${cliName}" command name, relying on spawn/PATH lookup.`);
146
- console.warn(`[Warning] Claude CLI not found at ~/.claude/local/claude. Falling back to "${cliName}" in PATH. Ensure it is installed and accessible.`);
147
- return cliName;
252
+ const status = getCliBinaryStatus('claude');
253
+ return getCliCommandOrThrow(status);
148
254
  }
package/src/cli.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
3
  import { buildCliCommand } from './cli-builder.js';
4
- import { findClaudeCli, findCodexCli, findGeminiCli } from './cli-utils.js';
4
+ import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
5
5
 
6
6
  /**
7
7
  * Minimal argv parser. No external dependencies.
@@ -35,12 +35,12 @@ function parseArgs(argv: string[]): Record<string, string> {
35
35
  const USAGE = `Usage: npm run -s cli.run -- --model <model> --workFolder <path> --prompt "..." [options]
36
36
 
37
37
  Options:
38
- --model Model name or alias (e.g. sonnet, opus, gpt-5.2-codex, gemini-2.5-pro)
38
+ --model Model name or alias (e.g. sonnet, opus, gpt-5.2-codex, gemini-2.5-pro, forge)
39
39
  --workFolder Working directory (absolute path)
40
40
  --prompt Prompt string (mutually exclusive with --prompt_file)
41
41
  --prompt_file Path to a file containing the prompt
42
42
  --session_id Session ID to resume
43
- --reasoning_effort Claude/Codex: Claude=low|medium|high, Codex=low|medium|high|xhigh
43
+ --reasoning_effort Claude/Codex only: Claude=low|medium|high, Codex=low|medium|high|xhigh
44
44
  --help Show this help message
45
45
 
46
46
  Raw CLI output goes to stdout. Use cli.run.parse to parse the output:
@@ -73,6 +73,7 @@ async function main(): Promise<void> {
73
73
  claude: findClaudeCli(),
74
74
  codex: findCodexCli(),
75
75
  gemini: findGeminiCli(),
76
+ forge: findForgeCli(),
76
77
  };
77
78
 
78
79
  // Build command
@@ -0,0 +1,64 @@
1
+ export const CLAUDE_MODELS = ['sonnet', 'sonnet[1m]', 'opus', 'opusplan', 'haiku'] as const;
2
+ export const CODEX_MODELS = [
3
+ 'gpt-5.4',
4
+ 'gpt-5.3-codex',
5
+ 'gpt-5.2-codex',
6
+ 'gpt-5.1-codex-mini',
7
+ 'gpt-5.1-codex-max',
8
+ 'gpt-5.2',
9
+ 'gpt-5.1',
10
+ 'gpt-5.1-codex',
11
+ 'gpt-5-codex',
12
+ 'gpt-5-codex-mini',
13
+ 'gpt-5',
14
+ ] as const;
15
+ export const GEMINI_MODELS = [
16
+ 'gemini-2.5-pro',
17
+ 'gemini-2.5-flash',
18
+ 'gemini-3.1-pro-preview',
19
+ 'gemini-3-pro-preview',
20
+ 'gemini-3-flash-preview',
21
+ ] as const;
22
+ export const FORGE_MODELS = ['forge'] as const;
23
+
24
+ export const MODEL_ALIASES: Record<string, string> = {
25
+ 'claude-ultra': 'opus',
26
+ 'codex-ultra': 'gpt-5.4',
27
+ 'gemini-ultra': 'gemini-3.1-pro-preview',
28
+ };
29
+
30
+ export const MODEL_ALIAS_DETAILS = [
31
+ { name: 'claude-ultra', resolvesTo: 'opus', agent: 'claude', defaultReasoningEffort: 'high' },
32
+ { name: 'codex-ultra', resolvesTo: 'gpt-5.4', agent: 'codex', defaultReasoningEffort: 'xhigh' },
33
+ { name: 'gemini-ultra', resolvesTo: 'gemini-3.1-pro-preview', agent: 'gemini' },
34
+ ] as const;
35
+
36
+ export function getSupportedModelsDescription(): string {
37
+ return [
38
+ '"claude-ultra", "codex-ultra", "gemini-ultra"',
39
+ ...CLAUDE_MODELS.map((model) => `"${model}"`),
40
+ ...CODEX_MODELS.map((model) => `"${model}"`),
41
+ ...GEMINI_MODELS.map((model) => `"${model}"`),
42
+ ...FORGE_MODELS.map((model) => `"${model}"`),
43
+ ].join(', ');
44
+ }
45
+
46
+ export function getModelParameterDescription(): string {
47
+ return `The model to use. Aliases: "claude-ultra" (auto high effort), "codex-ultra" (auto xhigh reasoning), "gemini-ultra". Standard: ${[...CLAUDE_MODELS, ...CODEX_MODELS, ...GEMINI_MODELS, ...FORGE_MODELS].map((model) => `"${model}"`).join(', ')}. "forge" is a provider key, not a Forge model family selector.`;
48
+ }
49
+
50
+ export function getModelsPayload(): {
51
+ aliases: ReadonlyArray<(typeof MODEL_ALIAS_DETAILS)[number]>;
52
+ claude: ReadonlyArray<string>;
53
+ codex: ReadonlyArray<string>;
54
+ gemini: ReadonlyArray<string>;
55
+ forge: ReadonlyArray<string>;
56
+ } {
57
+ return {
58
+ aliases: MODEL_ALIAS_DETAILS,
59
+ claude: CLAUDE_MODELS,
60
+ codex: CODEX_MODELS,
61
+ gemini: GEMINI_MODELS,
62
+ forge: FORGE_MODELS,
63
+ };
64
+ }
package/src/parsers.ts CHANGED
@@ -167,3 +167,64 @@ export function parseGeminiOutput(stdout: string): any {
167
167
  return null;
168
168
  }
169
169
  }
170
+
171
+ /**
172
+ * Parse Forge output framed by Initialize/Continue/Finished markers.
173
+ */
174
+ export function parseForgeOutput(stdout: string): any {
175
+ if (!stdout) return null;
176
+
177
+ const lines = stdout.split('\n');
178
+ const markerPattern = /^● \[[^\]]+\] (Initialize|Continue|Finished) (\S+)\s*$/;
179
+ let collecting = false;
180
+ let currentConversationId: string | null = null;
181
+ let currentBody: string[] = [];
182
+ let lastConversationId: string | null = null;
183
+ let lastMessage: string | null = null;
184
+
185
+ for (const line of lines) {
186
+ const match = line.match(markerPattern);
187
+ if (match) {
188
+ const [, action, conversationId] = match;
189
+ lastConversationId = conversationId;
190
+
191
+ if (action === 'Initialize' || action === 'Continue') {
192
+ collecting = true;
193
+ currentConversationId = conversationId;
194
+ currentBody = [];
195
+ } else if (collecting && currentConversationId === conversationId) {
196
+ const message = currentBody.join('\n').trim();
197
+ if (message) {
198
+ lastMessage = message;
199
+ }
200
+ collecting = false;
201
+ currentConversationId = null;
202
+ currentBody = [];
203
+ }
204
+ continue;
205
+ }
206
+
207
+ if (collecting) {
208
+ currentBody.push(line);
209
+ }
210
+ }
211
+
212
+ if (collecting) {
213
+ const message = currentBody.join('\n').trim();
214
+ if (message) {
215
+ lastMessage = message;
216
+ }
217
+ if (currentConversationId) {
218
+ lastConversationId = currentConversationId;
219
+ }
220
+ }
221
+
222
+ if (!lastMessage && !lastConversationId) {
223
+ return null;
224
+ }
225
+
226
+ return {
227
+ message: lastMessage,
228
+ session_id: lastConversationId,
229
+ };
230
+ }