kimaki 0.4.34 → 0.4.35

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.
package/dist/cli.js CHANGED
@@ -135,6 +135,13 @@ async function registerCommands(token, appId, userCommands = []) {
135
135
  .setAutocomplete(true)
136
136
  .setMaxLength(6000);
137
137
  return option;
138
+ })
139
+ .addStringOption((option) => {
140
+ option
141
+ .setName('agent')
142
+ .setDescription('Agent to use for this session')
143
+ .setAutocomplete(true);
144
+ return option;
138
145
  })
139
146
  .toJSON(),
140
147
  new SlashCommandBuilder()
@@ -13,6 +13,7 @@ export async function handleSessionCommand({ command, appId, }) {
13
13
  await command.deferReply({ ephemeral: false });
14
14
  const prompt = command.options.getString('prompt', true);
15
15
  const filesString = command.options.getString('files') || '';
16
+ const agent = command.options.getString('agent') || undefined;
16
17
  const channel = command.channel;
17
18
  if (!channel || channel.type !== ChannelType.GuildText) {
18
19
  await command.editReply('This command can only be used in text channels');
@@ -66,6 +67,7 @@ export async function handleSessionCommand({ command, appId, }) {
66
67
  thread,
67
68
  projectDirectory,
68
69
  channelId: textChannel.id,
70
+ agent,
69
71
  });
70
72
  }
71
73
  catch (error) {
@@ -73,8 +75,61 @@ export async function handleSessionCommand({ command, appId, }) {
73
75
  await command.editReply(`Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`);
74
76
  }
75
77
  }
78
+ async function handleAgentAutocomplete({ interaction, appId, }) {
79
+ const focusedValue = interaction.options.getFocused();
80
+ let projectDirectory;
81
+ if (interaction.channel) {
82
+ const channel = interaction.channel;
83
+ if (channel.type === ChannelType.GuildText) {
84
+ const textChannel = channel;
85
+ if (textChannel.topic) {
86
+ const extracted = extractTagsArrays({
87
+ xml: textChannel.topic,
88
+ tags: ['kimaki.directory', 'kimaki.app'],
89
+ });
90
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim();
91
+ if (channelAppId && channelAppId !== appId) {
92
+ await interaction.respond([]);
93
+ return;
94
+ }
95
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
96
+ }
97
+ }
98
+ }
99
+ if (!projectDirectory) {
100
+ await interaction.respond([]);
101
+ return;
102
+ }
103
+ try {
104
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
105
+ const agentsResponse = await getClient().app.agents({
106
+ query: { directory: projectDirectory },
107
+ });
108
+ if (!agentsResponse.data || agentsResponse.data.length === 0) {
109
+ await interaction.respond([]);
110
+ return;
111
+ }
112
+ const agents = agentsResponse.data
113
+ .filter((a) => a.mode === 'primary' || a.mode === 'all')
114
+ .filter((a) => a.name.toLowerCase().includes(focusedValue.toLowerCase()))
115
+ .slice(0, 25);
116
+ const choices = agents.map((agent) => ({
117
+ name: agent.name.slice(0, 100),
118
+ value: agent.name,
119
+ }));
120
+ await interaction.respond(choices);
121
+ }
122
+ catch (error) {
123
+ logger.error('[AUTOCOMPLETE] Error fetching agents:', error);
124
+ await interaction.respond([]);
125
+ }
126
+ }
76
127
  export async function handleSessionAutocomplete({ interaction, appId, }) {
77
128
  const focusedOption = interaction.options.getFocused(true);
129
+ if (focusedOption.name === 'agent') {
130
+ await handleAgentAutocomplete({ interaction, appId });
131
+ return;
132
+ }
78
133
  if (focusedOption.name !== 'files') {
79
134
  return;
80
135
  }
package/dist/opencode.js CHANGED
@@ -87,8 +87,7 @@ export async function initializeOpencodeForDirectory(directory) {
87
87
  throw new Error(`Directory does not exist or is not accessible: ${directory}`);
88
88
  }
89
89
  const port = await getOpenPort();
90
- const opencodeBinDir = `${process.env.HOME}/.opencode/bin`;
91
- const opencodeCommand = process.env.OPENCODE_PATH || `${opencodeBinDir}/opencode`;
90
+ const opencodeCommand = process.env.OPENCODE_PATH || 'opencode';
92
91
  const serverProcess = spawn(opencodeCommand, ['serve', '--port', port.toString()], {
93
92
  stdio: 'pipe',
94
93
  detached: false,
@@ -2,7 +2,7 @@
2
2
  // Creates, maintains, and sends prompts to OpenCode sessions from Discord threads.
3
3
  // Handles streaming events, permissions, abort signals, and message queuing.
4
4
  import prettyMilliseconds from 'pretty-ms';
5
- import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js';
5
+ import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent, setSessionAgent } from './database.js';
6
6
  import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js';
7
7
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js';
8
8
  import { formatPart } from './message-formatting.js';
@@ -85,7 +85,7 @@ export async function abortAndRetrySession({ sessionId, thread, projectDirectory
85
85
  });
86
86
  return true;
87
87
  }
88
- export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], channelId, command, }) {
88
+ export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], channelId, command, agent, }) {
89
89
  voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
90
90
  const sessionStartTime = Date.now();
91
91
  const directory = projectDirectory || process.cwd();
@@ -127,6 +127,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
127
127
  .prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
128
128
  .run(thread.id, session.id);
129
129
  sessionLogger.log(`Stored session ${session.id} for thread ${thread.id}`);
130
+ // Store agent preference if provided
131
+ if (agent) {
132
+ setSessionAgent(session.id, agent);
133
+ sessionLogger.log(`Set agent preference for session ${session.id}: ${agent}`);
134
+ }
130
135
  const existingController = abortControllers.get(session.id);
131
136
  if (existingController) {
132
137
  voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`);
@@ -648,15 +653,17 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
648
653
  discordLogger.log(`Could not update reaction:`, e);
649
654
  }
650
655
  }
651
- const errorName = error &&
652
- typeof error === 'object' &&
653
- 'constructor' in error &&
654
- error.constructor &&
655
- typeof error.constructor.name === 'string'
656
- ? error.constructor.name
657
- : typeof error;
658
- const errorMsg = error instanceof Error ? error.stack || error.message : String(error);
659
- await sendThreadMessage(thread, `✗ Unexpected bot Error: [${errorName}]\n${errorMsg}`);
656
+ const errorDisplay = (() => {
657
+ if (error instanceof Error) {
658
+ const name = error.constructor.name || 'Error';
659
+ return `[${name}]\n${error.stack || error.message}`;
660
+ }
661
+ if (typeof error === 'string') {
662
+ return error;
663
+ }
664
+ return String(error);
665
+ })();
666
+ await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`);
660
667
  }
661
668
  }
662
669
  }
@@ -107,9 +107,11 @@ function renderSegments(segments) {
107
107
  }
108
108
  else {
109
109
  // Raw content (no prefix means it's original raw)
110
- result.push(segment.content);
110
+ // Ensure raw ends with newline for proper separation from next segment
111
+ const raw = segment.content.trimEnd();
112
+ result.push(raw + '\n');
111
113
  }
112
114
  }
113
115
  }
114
- return result.join('');
116
+ return result.join('').trimEnd();
115
117
  }
@@ -11,8 +11,7 @@ test('basic - single item with code block', () => {
11
11
 
12
12
  \`\`\`js
13
13
  const x = 1
14
- \`\`\`
15
- "
14
+ \`\`\`"
16
15
  `);
17
16
  });
18
17
  test('multiple items - code in middle item only', () => {
@@ -50,8 +49,7 @@ test('multiple code blocks in one item', () => {
50
49
  \`\`\`
51
50
  \`\`\`python
52
51
  b = 2
53
- \`\`\`
54
- "
52
+ \`\`\`"
55
53
  `);
56
54
  });
57
55
  test('nested list with code', () => {
@@ -125,8 +123,7 @@ test('mixed - some items have code, some dont', () => {
125
123
 
126
124
  \`\`\`python
127
125
  y = 2
128
- \`\`\`
129
- "
126
+ \`\`\`"
130
127
  `);
131
128
  });
132
129
  test('text before and after code in same item', () => {
@@ -142,8 +139,7 @@ test('text before and after code in same item', () => {
142
139
  \`\`\`js
143
140
  const x = 1
144
141
  \`\`\`
145
- - End text
146
- "
142
+ - End text"
147
143
  `);
148
144
  });
149
145
  test('preserves content outside lists', () => {
@@ -169,7 +165,6 @@ More text after.`;
169
165
  const x = 1
170
166
  \`\`\`
171
167
 
172
-
173
168
  More text after."
174
169
  `);
175
170
  });
@@ -195,8 +190,7 @@ test('handles code block without language', () => {
195
190
 
196
191
  \`\`\`
197
192
  plain code
198
- \`\`\`
199
- "
193
+ \`\`\`"
200
194
  `);
201
195
  });
202
196
  test('handles empty list item with code', () => {
@@ -207,8 +201,7 @@ test('handles empty list item with code', () => {
207
201
  expect(result).toMatchInlineSnapshot(`
208
202
  "\`\`\`js
209
203
  const x = 1
210
- \`\`\`
211
- "
204
+ \`\`\`"
212
205
  `);
213
206
  });
214
207
  test('numbered list with text after code block', () => {
@@ -321,7 +314,8 @@ test('deeply nested list with code', () => {
321
314
  deep code
322
315
  \`\`\`
323
316
  - Text after deep code
324
- - Another level 3- Back to level 2"
317
+ - Another level 3
318
+ - Back to level 2"
325
319
  `);
326
320
  });
327
321
  test('nested numbered list inside unordered with code', () => {
@@ -342,7 +336,8 @@ test('nested numbered list inside unordered with code', () => {
342
336
  code
343
337
  \`\`\`
344
338
  - Text after
345
- 2. Second nested- Another unordered"
339
+ 2. Second nested
340
+ - Another unordered"
346
341
  `);
347
342
  });
348
343
  test('code block at end of numbered item no text after', () => {
@@ -405,3 +400,33 @@ test('code block immediately after list marker', () => {
405
400
  2. Normal item"
406
401
  `);
407
402
  });
403
+ test('code block with filename metadata', () => {
404
+ const input = `- Item with code
405
+ \`\`\`tsx filename=example.tsx
406
+ const x = 1
407
+ \`\`\``;
408
+ const result = unnestCodeBlocksFromLists(input);
409
+ expect(result).toMatchInlineSnapshot(`
410
+ "- Item with code
411
+
412
+ \`\`\`tsx filename=example.tsx
413
+ const x = 1
414
+ \`\`\`"
415
+ `);
416
+ });
417
+ test('numbered list with filename metadata code block', () => {
418
+ const input = `1. First item
419
+ \`\`\`tsx filename=app.tsx
420
+ export default function App() {}
421
+ \`\`\`
422
+ 2. Second item`;
423
+ const result = unnestCodeBlocksFromLists(input);
424
+ expect(result).toMatchInlineSnapshot(`
425
+ "1. First item
426
+
427
+ \`\`\`tsx filename=app.tsx
428
+ export default function App() {}
429
+ \`\`\`
430
+ 2. Second item"
431
+ `);
432
+ });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.34",
5
+ "version": "0.4.35",
6
6
  "scripts": {
7
7
  "dev": "tsx --env-file .env src/cli.ts",
8
8
  "prepublishOnly": "pnpm tsc",
package/src/cli.ts CHANGED
@@ -197,6 +197,14 @@ async function registerCommands(token: string, appId: string, userCommands: Open
197
197
 
198
198
  return option
199
199
  })
200
+ .addStringOption((option) => {
201
+ option
202
+ .setName('agent')
203
+ .setDescription('Agent to use for this session')
204
+ .setAutocomplete(true)
205
+
206
+ return option
207
+ })
200
208
  .toJSON(),
201
209
  new SlashCommandBuilder()
202
210
  .setName('add-project')
@@ -21,6 +21,7 @@ export async function handleSessionCommand({
21
21
 
22
22
  const prompt = command.options.getString('prompt', true)
23
23
  const filesString = command.options.getString('files') || ''
24
+ const agent = command.options.getString('agent') || undefined
24
25
  const channel = command.channel
25
26
 
26
27
  if (!channel || channel.type !== ChannelType.GuildText) {
@@ -91,6 +92,7 @@ export async function handleSessionCommand({
91
92
  thread,
92
93
  projectDirectory,
93
94
  channelId: textChannel.id,
95
+ agent,
94
96
  })
95
97
  } catch (error) {
96
98
  logger.error('[SESSION] Error:', error)
@@ -100,12 +102,78 @@ export async function handleSessionCommand({
100
102
  }
101
103
  }
102
104
 
105
+ async function handleAgentAutocomplete({
106
+ interaction,
107
+ appId,
108
+ }: AutocompleteContext): Promise<void> {
109
+ const focusedValue = interaction.options.getFocused()
110
+
111
+ let projectDirectory: string | undefined
112
+
113
+ if (interaction.channel) {
114
+ const channel = interaction.channel
115
+ if (channel.type === ChannelType.GuildText) {
116
+ const textChannel = channel as TextChannel
117
+ if (textChannel.topic) {
118
+ const extracted = extractTagsArrays({
119
+ xml: textChannel.topic,
120
+ tags: ['kimaki.directory', 'kimaki.app'],
121
+ })
122
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim()
123
+ if (channelAppId && channelAppId !== appId) {
124
+ await interaction.respond([])
125
+ return
126
+ }
127
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
128
+ }
129
+ }
130
+ }
131
+
132
+ if (!projectDirectory) {
133
+ await interaction.respond([])
134
+ return
135
+ }
136
+
137
+ try {
138
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
139
+
140
+ const agentsResponse = await getClient().app.agents({
141
+ query: { directory: projectDirectory },
142
+ })
143
+
144
+ if (!agentsResponse.data || agentsResponse.data.length === 0) {
145
+ await interaction.respond([])
146
+ return
147
+ }
148
+
149
+ const agents = agentsResponse.data
150
+ .filter((a) => a.mode === 'primary' || a.mode === 'all')
151
+ .filter((a) => a.name.toLowerCase().includes(focusedValue.toLowerCase()))
152
+ .slice(0, 25)
153
+
154
+ const choices = agents.map((agent) => ({
155
+ name: agent.name.slice(0, 100),
156
+ value: agent.name,
157
+ }))
158
+
159
+ await interaction.respond(choices)
160
+ } catch (error) {
161
+ logger.error('[AUTOCOMPLETE] Error fetching agents:', error)
162
+ await interaction.respond([])
163
+ }
164
+ }
165
+
103
166
  export async function handleSessionAutocomplete({
104
167
  interaction,
105
168
  appId,
106
169
  }: AutocompleteContext): Promise<void> {
107
170
  const focusedOption = interaction.options.getFocused(true)
108
171
 
172
+ if (focusedOption.name === 'agent') {
173
+ await handleAgentAutocomplete({ interaction, appId })
174
+ return
175
+ }
176
+
109
177
  if (focusedOption.name !== 'files') {
110
178
  return
111
179
  }
package/src/opencode.ts CHANGED
@@ -115,8 +115,7 @@ export async function initializeOpencodeForDirectory(directory: string) {
115
115
 
116
116
  const port = await getOpenPort()
117
117
 
118
- const opencodeBinDir = `${process.env.HOME}/.opencode/bin`
119
- const opencodeCommand = process.env.OPENCODE_PATH || `${opencodeBinDir}/opencode`
118
+ const opencodeCommand = process.env.OPENCODE_PATH || 'opencode'
120
119
 
121
120
  const serverProcess = spawn(
122
121
  opencodeCommand,
@@ -6,7 +6,7 @@ import type { Part, PermissionRequest } from '@opencode-ai/sdk/v2'
6
6
  import type { FilePartInput } from '@opencode-ai/sdk'
7
7
  import type { Message, ThreadChannel } from 'discord.js'
8
8
  import prettyMilliseconds from 'pretty-ms'
9
- import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js'
9
+ import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent, setSessionAgent } from './database.js'
10
10
  import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js'
11
11
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js'
12
12
  import { formatPart } from './message-formatting.js'
@@ -141,6 +141,7 @@ export async function handleOpencodeSession({
141
141
  images = [],
142
142
  channelId,
143
143
  command,
144
+ agent,
144
145
  }: {
145
146
  prompt: string
146
147
  thread: ThreadChannel
@@ -150,6 +151,8 @@ export async function handleOpencodeSession({
150
151
  channelId?: string
151
152
  /** If set, uses session.command API instead of session.prompt */
152
153
  command?: { name: string; arguments: string }
154
+ /** Agent to use for this session */
155
+ agent?: string
153
156
  }): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
154
157
  voiceLogger.log(
155
158
  `[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
@@ -209,6 +212,12 @@ export async function handleOpencodeSession({
209
212
  .run(thread.id, session.id)
210
213
  sessionLogger.log(`Stored session ${session.id} for thread ${thread.id}`)
211
214
 
215
+ // Store agent preference if provided
216
+ if (agent) {
217
+ setSessionAgent(session.id, agent)
218
+ sessionLogger.log(`Set agent preference for session ${session.id}: ${agent}`)
219
+ }
220
+
212
221
  const existingController = abortControllers.get(session.id)
213
222
  if (existingController) {
214
223
  voiceLogger.log(
@@ -825,20 +834,17 @@ export async function handleOpencodeSession({
825
834
  discordLogger.log(`Could not update reaction:`, e)
826
835
  }
827
836
  }
828
- const errorName =
829
- error &&
830
- typeof error === 'object' &&
831
- 'constructor' in error &&
832
- error.constructor &&
833
- typeof error.constructor.name === 'string'
834
- ? error.constructor.name
835
- : typeof error
836
- const errorMsg =
837
- error instanceof Error ? error.stack || error.message : String(error)
838
- await sendThreadMessage(
839
- thread,
840
- `✗ Unexpected bot Error: [${errorName}]\n${errorMsg}`,
841
- )
837
+ const errorDisplay = (() => {
838
+ if (error instanceof Error) {
839
+ const name = error.constructor.name || 'Error'
840
+ return `[${name}]\n${error.stack || error.message}`
841
+ }
842
+ if (typeof error === 'string') {
843
+ return error
844
+ }
845
+ return String(error)
846
+ })()
847
+ await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`)
842
848
  }
843
849
  }
844
850
  }
@@ -12,8 +12,7 @@ test('basic - single item with code block', () => {
12
12
 
13
13
  \`\`\`js
14
14
  const x = 1
15
- \`\`\`
16
- "
15
+ \`\`\`"
17
16
  `)
18
17
  })
19
18
 
@@ -53,8 +52,7 @@ test('multiple code blocks in one item', () => {
53
52
  \`\`\`
54
53
  \`\`\`python
55
54
  b = 2
56
- \`\`\`
57
- "
55
+ \`\`\`"
58
56
  `)
59
57
  })
60
58
 
@@ -132,8 +130,7 @@ test('mixed - some items have code, some dont', () => {
132
130
 
133
131
  \`\`\`python
134
132
  y = 2
135
- \`\`\`
136
- "
133
+ \`\`\`"
137
134
  `)
138
135
  })
139
136
 
@@ -150,8 +147,7 @@ test('text before and after code in same item', () => {
150
147
  \`\`\`js
151
148
  const x = 1
152
149
  \`\`\`
153
- - End text
154
- "
150
+ - End text"
155
151
  `)
156
152
  })
157
153
 
@@ -178,7 +174,6 @@ More text after.`
178
174
  const x = 1
179
175
  \`\`\`
180
176
 
181
-
182
177
  More text after."
183
178
  `)
184
179
  })
@@ -206,8 +201,7 @@ test('handles code block without language', () => {
206
201
 
207
202
  \`\`\`
208
203
  plain code
209
- \`\`\`
210
- "
204
+ \`\`\`"
211
205
  `)
212
206
  })
213
207
 
@@ -219,8 +213,7 @@ test('handles empty list item with code', () => {
219
213
  expect(result).toMatchInlineSnapshot(`
220
214
  "\`\`\`js
221
215
  const x = 1
222
- \`\`\`
223
- "
216
+ \`\`\`"
224
217
  `)
225
218
  })
226
219
 
@@ -338,7 +331,8 @@ test('deeply nested list with code', () => {
338
331
  deep code
339
332
  \`\`\`
340
333
  - Text after deep code
341
- - Another level 3- Back to level 2"
334
+ - Another level 3
335
+ - Back to level 2"
342
336
  `)
343
337
  })
344
338
 
@@ -360,7 +354,8 @@ test('nested numbered list inside unordered with code', () => {
360
354
  code
361
355
  \`\`\`
362
356
  - Text after
363
- 2. Second nested- Another unordered"
357
+ 2. Second nested
358
+ - Another unordered"
364
359
  `)
365
360
  })
366
361
 
@@ -426,3 +421,35 @@ test('code block immediately after list marker', () => {
426
421
  2. Normal item"
427
422
  `)
428
423
  })
424
+
425
+ test('code block with filename metadata', () => {
426
+ const input = `- Item with code
427
+ \`\`\`tsx filename=example.tsx
428
+ const x = 1
429
+ \`\`\``
430
+ const result = unnestCodeBlocksFromLists(input)
431
+ expect(result).toMatchInlineSnapshot(`
432
+ "- Item with code
433
+
434
+ \`\`\`tsx filename=example.tsx
435
+ const x = 1
436
+ \`\`\`"
437
+ `)
438
+ })
439
+
440
+ test('numbered list with filename metadata code block', () => {
441
+ const input = `1. First item
442
+ \`\`\`tsx filename=app.tsx
443
+ export default function App() {}
444
+ \`\`\`
445
+ 2. Second item`
446
+ const result = unnestCodeBlocksFromLists(input)
447
+ expect(result).toMatchInlineSnapshot(`
448
+ "1. First item
449
+
450
+ \`\`\`tsx filename=app.tsx
451
+ export default function App() {}
452
+ \`\`\`
453
+ 2. Second item"
454
+ `)
455
+ })
@@ -123,10 +123,12 @@ function renderSegments(segments: Segment[]): string {
123
123
  result.push(segment.prefix + segment.content + '\n')
124
124
  } else {
125
125
  // Raw content (no prefix means it's original raw)
126
- result.push(segment.content)
126
+ // Ensure raw ends with newline for proper separation from next segment
127
+ const raw = segment.content.trimEnd()
128
+ result.push(raw + '\n')
127
129
  }
128
130
  }
129
131
  }
130
132
 
131
- return result.join('')
133
+ return result.join('').trimEnd()
132
134
  }