diff --git a/README.md b/README.md index d9febba..67f2bc3 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,12 @@ Note: as an alternative to explicitly passing the API keys in the constructor yo Request a non-streaming completion from the model. +#### `requestChatCompletion(model: Model, options: { messages: Message[], generationOptions: CompletionOptions }, chunkCb: ChunkCb): Promise` + +Request a completion from the model with a sequence of chat messages which have roles. A message should look like +`{ role: 'user', content: 'Hello!' }` or `{ role: 'system', content: 'Hi!' }`. The `role` can be either `user`, `system` or `assistant`, no +matter the model in use. + ### ChatSession #### `constructor(completionService: CompletionService, model: string, systemPrompt: string)` @@ -110,6 +116,13 @@ loadPrompt("Hello, may name is %%%(NAME)%%%", { NAME: "Omega" }) * `importPromptSync(path: string, variables: Record): string` - Load a prompt from a file with the given variables * `importPrompt(path: string, variables: Record): Promise` - Load a prompt from a file with the given variables, asynchronously returning a Promise +### Flow + +For building complex multi-round conversations or agents, see the `Flow` class. It allows you to define a flow of messages +and responses, and then run them in a sequence, with the ability to ask follow-up questions based on the previous responses. + +See the documentation [here](./docs/flow.md). + ### More information For the full API, see the [TypeScript types](./src/index.d.ts). Not all methods are documented in README, the types are diff --git a/docs/MarkdownProcessing.md b/docs/MarkdownProcessing.md index be0b95b..780a2ca 100644 --- a/docs/MarkdownProcessing.md +++ b/docs/MarkdownProcessing.md @@ -1,4 +1,4 @@ -LXL contains a simple templating system that allows you to conditionally insert data into the markdown at runtime. +LXL contains a simple templating system ("MDP") that allows you to conditionally insert data into the markdown at runtime. To insert variables, you use `%%%(VARIABLE_NAME)%%%`. To insert a string based on a specific condition, you use ```%%%[...] if CONDITION%%%``` or with an else clause, ```%%%[...] if BOOLEAN_VAR else [...]%%%```. Note that the square brackets work like quotation mark strings in programming languages, so you can escape them with a backslash, like `\]`. @@ -12,6 +12,7 @@ Would be loaded in JS like this: ```js const { importPromptSync } = require('langxlang') const prompt = importPromptSync('path-to-prompt.md', { NAME: 'Omega', HAS_PROMPT: true, IS_AI_STUDIO: false, LLM_NAME: 'Gemini 1.5 Pro' }) +console.log(prompt) // string ``` And would result in: @@ -38,50 +39,48 @@ You are running via API. Note: you can optionally put 2 spaces of tabulation in each line after an IF or ELSE block to make the code more readable. This is optional, and will be removed by the parser. If you actually want to put 2+ spaces of tabs, you can add an extra 2 spaces of tabulation (eg use 4 spaces to get 2, 6 to get 4, etc.). -### Guidance Region +#### Comments -LLMs don't always give you the output you desire for your prompt. One approach to fixing -this is by modifying your prompt to be more clear and adding more examples. -Another standard approach is to provide some initial guidance to the model for what the response -should look like, to guarantee that the model will give you output similar to that you desire. -For example, instead of just asking your model to output JSON or YAML and then -ending the prompt with a question for the model to answer, you might end it with -a markdown code block (like ```yml), that the LLM would then complete the -body for. +You can specify a comment by using `` (note the additional dash in the starting token). This will be removed by the parser and not included in the output. We don't remove standard `' }, + { type: 'section', title: 'Comments', children: [ + { type: 'section', title: 'src/index.js', children: [ + { type: 'section', title: 'Line', children: [ + { type: 'text', text: '```js' } + { type: 'text', text: 'let aple = 1' } + { type: 'text', text: '```' } + ] }, + ... + ] } + ]} +] +*/ + +function countStart (str, char) { + let c = 0 + for (const s of str) { + if (s === char) c++ + else break + } + return c +} + +function parseMarkdown (text, options) { + const tokens = tokenizeMarkdown(text, {}) + // pre-process the data a bit + const data = [] + for (const token of tokens) { + if (token[1] === 'text') { + const text = token[0] + for (const line of text.split('\n')) { + if (line.startsWith('#')) { + const level = countStart(line, '#') + data.push({ type: 'header', level, text: line.slice(level).trim() }) + } else { + data.push({ type: 'text', text: line }) + } + } + } else if (token[1] === 'code') { + data.push({ type: 'code', raw: token[0], lang: token[2], code: token[3] }) + } else if (token[1] === 'pre') { + data.push({ type: 'pre', raw: token[0], lang: token[2], code: token[3] }) + } else if (token[1] === 'preformat') { + data.push({ type: 'preformat', raw: token[0], code: token[2] }) + } else if (token[1] === 'comment') { + data.push({ type: 'comment', raw: token[0] }) + } + } + + const inter = [] + let current = inter + let currentHeader = null + for (let i = 0; i < data.length; i++) { + const line = data[i] + if (line.type === 'header') { + if (!currentHeader) { + currentHeader = { type: 'section', level: line.level, title: line.text, children: [], parent: null } + current.push(currentHeader) + current = currentHeader.children + } else { + while (currentHeader.level >= line.level) { + currentHeader = currentHeader.parent + current = currentHeader.children + } + const newHeader = { type: 'section', level: line.level, title: line.text, children: [], parent: currentHeader } + current.push(newHeader) + current = newHeader.children + currentHeader = newHeader + } + } else { + current.push(line) + } + } + // remove circular references + const structured = JSON.parse(JSON.stringify(inter, (key, value) => key === 'parent' ? undefined : value, 2)) + return { + lines: data, + structured + } +} + +module.exports = { parseMarkdown } diff --git a/src/tools/mdp.js b/src/tools/mdp.js index 6f9b76c..6e3cdc8 100644 --- a/src/tools/mdp.js +++ b/src/tools/mdp.js @@ -1,16 +1,12 @@ const fs = require('fs') const { join, dirname } = require('path') const getCaller = require('caller') -const { normalizeLineEndings } = require('./stripping') +const { stripMdpComments, normalizeLineEndings } = require('./stripping') // See doc/MarkdownPreprocessing.md for more information class PromptString extends String { - constructor (str, basePrompt, guidanceText) { - super(str) - this.basePrompt = basePrompt - this.guidanceText = guidanceText - } + } function preMarkdown (text, vars = {}) { @@ -24,6 +20,8 @@ function preMarkdown (text, vars = {}) { let tokens = [] let temp = '' let result = text + // First, strip out all the MDP comments () + result = stripMdpComments(text) // Handle conditional insertions first const TOKEN_COND_START = '%%%[' @@ -222,6 +220,35 @@ function preMarkdown (text, vars = {}) { return result } +const DEFAULT_ROLES = { + '<|SYSTEM|>': 'system', + '<|USER|>': 'user', + '<|ASSISTANT|>': 'assistant' +} + +function segmentByRoles (text, roles) { + // split the text into segments based on the roles + const segments = [] + for (let i = 0; i < text.length; i++) { + for (const role in roles) { + if (text.slice(i, i + role.length) === role) { + segments.push({ role, start: i, end: i + role.length }) + i += role.length + break + } + } + } + // now we can extract the text from each segment + const result = [] + for (let i = 0; i < segments.length; i++) { + const segment = segments[i] + const nextSegment = segments[i + 1] + const roleText = text.slice(segment.end, nextSegment ? nextSegment.start : text.length) + result.push({ role: roles[segment.role], content: roleText.trim() }) + } + return result.filter(x => x.content.trim() !== '') +} + // Wraps the contents by using the specified token character at least 3 times, // ensuring that the token is long enough that it's not present in the content function wrapContentWithSufficientTokens (content, token = '`', initialTokenSuffix = '') { @@ -237,17 +264,20 @@ function wrapContentWithSufficientTokens (content, token = '`', initialTokenSuff return lines } -function loadPrompt (text, vars) { - // Prevent user data from affecting the guidance token by using a intermediate random string - const TOKEN_GUIDANCE_START = '%%%$GUIDANCE_START$%%%' - const tokenGuidanceStart = `%%%$GUIDANCE_START${Math.random()}$%%%` - text = text.replaceAll(TOKEN_GUIDANCE_START, tokenGuidanceStart) +function loadPrompt (text, vars, options = {}) { + const newRoles = {} + if (options.roles) { + // Prevent user data from affecting this + const roles = options.roles === true ? DEFAULT_ROLES : options.roles + for (const role in roles) { + const newRole = role + Math.random() + newRoles[newRole] = roles[role] + text = text.replaceAll(role, newRole) + } + } const str = preMarkdown(text.replaceAll('\r\n', '\n'), vars) - const guidanceText = str.indexOf(tokenGuidanceStart) - if (guidanceText !== -1) { - const [basePrompt, guidanceText] = str.split(tokenGuidanceStart) - const newStr = str.replace(tokenGuidanceStart, '') - return new PromptString(newStr, basePrompt, guidanceText) + if (options.roles) { + return segmentByRoles(str, newRoles) } else { return new PromptString(str) } @@ -278,14 +308,14 @@ function importPromptRaw (path) { return data.replaceAll('\r\n', '\n') } -function importPromptSync (path, vars) { +function importPromptSync (path, vars, opts) { const data = path.startsWith('.') ? readSync(path, getCaller(1)) : readSync(path) - return loadPrompt(data, vars) + return loadPrompt(data, vars, opts) } -async function importPrompt (path, vars) { +async function importPrompt (path, vars, opts) { let fullPath = path if (path.startsWith('.')) { const caller = getCaller(1) @@ -295,7 +325,7 @@ async function importPrompt (path, vars) { } try { const text = await fs.promises.readFile(fullPath, 'utf-8') - return loadPrompt(text, vars) + return loadPrompt(text, vars, opts) } catch (e) { if (!path.startsWith('.')) { throw new Error(`Failed to load prompt at specified path '${path}'. If you want to load a prompt relative to your script's current directory, you need to pass a relative path starting with './'`) @@ -304,4 +334,4 @@ async function importPrompt (path, vars) { } } -module.exports = { preMarkdown, wrapContentWithSufficientTokens, loadPrompt, importPromptSync, importPrompt, importPromptRaw } +module.exports = { preMarkdown, wrapContentWithSufficientTokens, segmentByRoles, loadPrompt, importPromptSync, importPrompt, importPromptRaw } diff --git a/src/tools/stripping.js b/src/tools/stripping.js index 7d4919c..0c4b097 100644 --- a/src/tools/stripping.js +++ b/src/tools/stripping.js @@ -10,6 +10,28 @@ function normalizeLineEndings (str) { return str.replace(/\r\n/g, '\n') } +function count (str, char) { + let c = 0 + for (const s of str) { + if (s === char) c++ + } + return c +} +function countStart (str, char) { + let c = 0 + for (const s of str) { + if (s === char) c++ + else break + } + return c +} +function stripXmlComments (text) { + return text.replace(//g, '') +} +function stripMdpComments (text) { + return text.replace(//g, '') +} + function stripJava (code, options) { // First, we need to "tokenize" the code, by splitting it into 3 types of data: comments, strings, and code. const tokens = [] @@ -426,40 +448,101 @@ function strOnlyContainsCharExcludingWhitespace (str, char) { } function tokenizeMarkdown (comment, options) { - // console.log('Tokenize', comment) const tokens = [] let tokenSoFar = '' let inCodeBlock = false let inCodeLang + let inPreTag = false + let linePadding = 0 for (let i = 0; i < comment.length; i++) { const currentChar = comment[i] + const lastChar = comment[i - 1] const slice = comment.slice(i) - if (inCodeBlock) { - // TODO: Check markdown spec -- does \n have to proceed the code block end? - // Because LLMs can't backtrack to escape a codeblock with more backticks after it's started - // writing, we need to check \n before closing block to make sure it's actually the end - if (slice.startsWith('\n' + inCodeBlock)) { - const code = tokenSoFar.slice(inCodeBlock.length + inCodeLang.length + 1) // +1 for the newline - tokens.push([tokenSoFar + '\n' + inCodeBlock, 'code', inCodeLang, code + '\n']) - i += inCodeBlock.length + if (lastChar === '\n') { + linePadding = countStart(slice.replace('\t', ' '), ' ') + } + if (inPreTag) { + if (slice.startsWith('')) { + tokens.push([tokenSoFar + '', 'pre']) + i += 5 + inPreTag = false + tokenSoFar = '' + } else { + tokenSoFar += currentChar + } + } else if (inCodeBlock) { + // This handles backticks closing code blocks. It's tricky as the markdown spec isn't clear on this. + // On top of that, once LLMs start generating text (for example with 3 backticks), they can't backtrack + // and add more backticks to the enclosing codeblock to avoid escaping problems. This means it is not + // possible to ascertain starting and ending code blocks just by looking at n-back tick chars, we must + // also on top make sure the padding for the start/stop backtick'ed lines are the same. This seems to work + // well and also handles tabulation, for example a paragraph that's got 1-3 spaces of indent (4+ would be a pre block). + if (slice.startsWith(inCodeBlock.tag) && (inCodeBlock.padding === linePadding)) { + const code = tokenSoFar.slice(inCodeBlock.tag.length + inCodeLang.length + 1) // +1 for the newline after ``` + tokens.push([tokenSoFar + inCodeBlock.tag, 'code', inCodeLang, code]) + i += inCodeBlock.tag.length inCodeBlock = false tokenSoFar = '' } else { tokenSoFar += currentChar } } else { + if (lastChar === '\n' && slice.startsWith(' ')) { + // Handle tab preformatted text blocks. + // This is a bit tricky as we need to check if the last line is empty or a markdown header before + // we can allow a preformatted block to start. Also, multiple subsequent preformatted blocks should + // be concatenated, or even text blocks if they are empty, so we have to concat afterwards in postproc. + const lastLine = tokenSoFar.slice(0, -1).split('\n').pop() + if (lastLine.trim() === '' || lastLine.startsWith('#')) { + // 4-space code block for this whole line + tokens.push([tokenSoFar, 'text']) + tokenSoFar = '' + let lineEnd = slice.indexOf('\n') + if (lineEnd === -1) lineEnd = slice.length + const raw = slice.slice(0, lineEnd + 1) + const code = slice.slice(4, lineEnd) + tokens.push([raw, 'preformat', code]) + i += lineEnd + continue + } + } + if (slice.startsWith('') + if (end === -1) { + if (options.allowMalformed) { + tokens.push([tokenSoFar, 'text']) + tokens.push([slice, 'comment']) + break + } else { + throw new Error('Unmatched markdown comment') + } + } else { + tokens.push([tokenSoFar, 'text']) + tokens.push([slice.slice(0, end + 3), 'comment']) + i += end + 2 + tokenSoFar = '' + } + continue + } + const preMatch = slice.match(/^
/)
       const codeMatch = slice.match(/^([`]{3,})([a-zA-Z]*)\n/)
       if (codeMatch) {
         tokens.push([tokenSoFar, 'text'])
-        inCodeBlock = codeMatch[1]
+        inCodeBlock = { tag: codeMatch[1], padding: linePadding }
         inCodeLang = codeMatch[2]
         tokenSoFar = codeMatch[0]
         i += tokenSoFar.length - 1
+      } else if (preMatch) {
+        tokens.push([tokenSoFar, 'text'])
+        inPreTag = true
+        tokenSoFar = preMatch[0]
+        i += tokenSoFar.length - 1
       } else {
         tokenSoFar += currentChar
       }
     }
   }
+
   if (inCodeBlock) {
     if (options.allowMalformed) {
       tokens.push([tokenSoFar, 'text'])
@@ -468,7 +551,35 @@ function tokenizeMarkdown (comment, options) {
     }
   }
   tokens.push([tokenSoFar, 'text'])
-  return tokens
+
+  // Now we need to merge adjacent preformatted blocks or preformatted blocks with spacing text blocks between
+  const updated = []
+  for (let i = 0; i < tokens.length; i++) {
+    const token = tokens[i]
+    if (token[1] === 'preformat') {
+      let intermediateEmptyLines = ''
+      for (let j = i + 1; j < tokens.length; j++) {
+        const nextToken = tokens[j]
+        if (nextToken[1] === 'preformat') {
+          if (intermediateEmptyLines) {
+            const lineCount = count(intermediateEmptyLines, '\n')
+            token[0] += intermediateEmptyLines
+            token[2] += '\n'.repeat(lineCount)
+            intermediateEmptyLines = ''
+          }
+          token[0] += nextToken[0]
+          token[2] += '\n' + nextToken[2]
+          i = j
+        } else if (nextToken[1] === 'text' && nextToken[0].trim() === '') {
+          intermediateEmptyLines += nextToken[0]
+        } else {
+          break
+        }
+      }
+    }
+    updated.push(token)
+  }
+  return updated
 }
 
 // This mainly erases extraneous new lines outside of code blocks, including ones with empty block quotes
@@ -541,4 +652,4 @@ function stripDiff (diff, options = {}) {
   return result.join('\n')
 }
 
-module.exports = { stripJava, stripPHP, stripGo, stripMarkdown, stripDiff, removeNonAscii, normalizeLineEndings, tokenizeMarkdown }
+module.exports = { stripJava, stripPHP, stripGo, stripMarkdown, stripDiff, removeNonAscii, normalizeLineEndings, tokenizeMarkdown, stripXmlComments, stripMdpComments }
diff --git a/src/tools/viz.js b/src/tools/viz.js
index 4d19bc7..1acec9c 100644
--- a/src/tools/viz.js
+++ b/src/tools/viz.js
@@ -85,15 +85,15 @@ async function makeVizForPrompt (system, user, models, options) {
     if (modelInfo.author === 'googleaistudio') {
       aiStudioService ??= new GoogleAIStudioCompletionService(options?.aiStudioPort)
       await aiStudioService.ready
-      const { text } = await aiStudioService.requestCompletion(model, system, user)
+      const [response] = await aiStudioService.requestCompletion(model, system, user)
       data.models.push([modelInfo.displayName, modelInfo.safeId])
-      data.outputs[modelInfo.safeId] = text
+      data.outputs[modelInfo.safeId] = response.text
       continue
     }
 
-    const { text } = await service.requestCompletion(model, system, user)
+    const [response] = await service.requestCompletion(model, system, user)
     data.models.push([modelInfo.displayName, modelInfo.safeId])
-    data.outputs[modelInfo.safeId] = text
+    data.outputs[modelInfo.safeId] = response.text
   }
 
   aiStudioService?.stop()
diff --git a/src/util.js b/src/util.js
index 210cf8e..fc109cb 100644
--- a/src/util.js
+++ b/src/util.js
@@ -52,4 +52,20 @@ async function sleep (ms) {
   })
 }
 
-module.exports = { sleep, cleanMessage, getModelInfo, getRateLimit, checkDoesGoogleModelSupportInstructions, knownModelInfo, knownModels }
+function checkGuidance (messages, chunkCb) {
+  const guidance = messages.filter((msg) => msg.role === 'guidance')
+  if (guidance.length > 1) {
+    throw new Error('Only one guidance message is supported')
+  } else if (guidance.length) {
+    // ensure it's the last message
+    const lastMsg = messages[messages.length - 1]
+    if (lastMsg !== guidance[0]) {
+      throw new Error('Guidance message must be the last message')
+    }
+    chunkCb?.({ done: false, content: guidance[0].content })
+    return guidance[0].content
+  }
+  return ''
+}
+
+module.exports = { sleep, cleanMessage, getModelInfo, getRateLimit, checkDoesGoogleModelSupportInstructions, checkGuidance, knownModelInfo, knownModels }
diff --git a/test/flow.test.js b/test/flow.test.js
index 0a727e1..9cc3a60 100644
--- a/test/flow.test.js
+++ b/test/flow.test.js
@@ -2,8 +2,7 @@
 const assert = require('assert')
 const { Flow } = require('langxlang')
 
-const chain = (params) => ({
-  prompt: `
+const promptText = `
 
 Hello, how are you doing today on this %%%(DAY_OF_WEEK)%%%?
 
@@ -22,7 +21,16 @@ Hello, how are you doing today on this %%%(DAY_OF_WEEK)%%%?
   ~~~
   
 %%%endif
-`.trim().replaceAll('~~~', '```'),
+`.trim().replaceAll('~~~', '```')
+
+const chain = (params) => ({
+  prompt: {
+    text: promptText,
+    roles: {
+      '': 'user',
+      '': 'assistant'
+    }
+  },
   with: {
     DAY_OF_WEEK: params.dayOfWeek
   },
@@ -44,9 +52,10 @@ Hello, how are you doing today on this %%%(DAY_OF_WEEK)%%%?
 })
 
 const dummyCompletionService = {
-  requestCompletion: async (model, systemPrompt, userPrompt, chunkCb, generationOpts) => {
-    const mergedPrompt = [systemPrompt, userPrompt].join('\n')
-    // console.log('mergedPrompt', mergedPrompt)
+  requestChatCompletion: async (model, { messages, generationOpts }, chunkCb) => {
+    assert(messages.every(msg => ['user', 'assistant'].includes(msg.role)))
+    const mergedPrompt = messages.map(m => m.content).join('\n')
+    // console.log('mergedPrompt', [mergedPrompt], messages)
     if (mergedPrompt.includes('YAML')) {
       return [{ text: 'Sure! Here is some yaml:\n```yaml\nare_ok: yes\n```\nI hope that helps!' }]
     } else if (mergedPrompt.includes('tomorrow')) {
diff --git a/test/testPromptRoles.md b/test/testPromptRoles.md
new file mode 100644
index 0000000..f5bc4bd
--- /dev/null
+++ b/test/testPromptRoles.md
@@ -0,0 +1,10 @@
+<|SYSTEM|>
+Respond to the user like a pirate.
+<|USER|>
+How are you today?
+<|ASSISTANT|>
+Arrr, I be doin' well, matey! How can I help ye today?
+<|USER|>
+What is the weather like?
+<|ASSISTANT|>
+Arrr, the weather be fair and mild, matey. Ye be safe to set sail!
\ No newline at end of file
diff --git a/test/testprompt.md b/test/testprompt.md
index 0a4b3f4..a3f53e2 100644
--- a/test/testprompt.md
+++ b/test/testprompt.md
@@ -5,4 +5,4 @@ You are running in Google AI Studio.
 %%%else
 You are running via API.
 %%%endif
-Done!
+Done!
diff --git a/test/tooling.test.js b/test/tooling.test.js
index d8bd031..deeea8a 100644
--- a/test/tooling.test.js
+++ b/test/tooling.test.js
@@ -29,6 +29,24 @@ describe('Basic tests', () => {
     // fs.writeFileSync('__encoded.yaml', encoded)
     yaml.load(encoded)
   })
+
+  it('md parsing works', function () {
+    const parsed = tools._parseMarkdown(testMd)
+    const json = JSON.stringify(parsed)
+    // console.log('Parsed', json)
+    assert.strictEqual(json, expectedMd)
+  })
+
+  it('mdp role processing', function () {
+    const prompt = tools.importRawSync('./testPromptRoles.md')
+    const messages = tools._segmentPromptByRoles(prompt, {
+      '<|SYSTEM|>': 'system',
+      '<|USER|>': 'user',
+      '<|ASSISTANT|>': 'assistant'
+    }) // use default roles
+    console.log('Messages', JSON.stringify(messages))
+    assert.strictEqual(JSON.stringify(messages), '[{"role":"system","content":"Respond to the user like a pirate."},{"role":"user","content":"How are you today?"},{"role":"assistant","content":"Arrr, I be doin\' well, matey! How can I help ye today?"},{"role":"user","content":"What is the weather like?"},{"role":"assistant","content":"Arrr, the weather be fair and mild, matey. Ye be safe to set sail!"}]')
+  })
 })
 
 describe('stripping', function () {
@@ -129,3 +147,28 @@ const mineflayer3213 = "- [X] The [FAQ](https://github.com/PrismarineJS/mineflay
 
 const expectedStrip33 = 'Are you saying the automatic doc deploy script is broken? It seems to be\nworking to me. Please explain what has changed in the source and what\nshould be different on the html page.'
 const expectedStrip3213 = "- [X] The [FAQ](https://github.com/PrismarineJS/mineflayer/blob/master/docs/FAQ.md) doesn't contain a resolution to my issue \n## Versions\n - mineflayer: 4.14.0\n - server: vanilla 1.20.1\n - node: 20.8.0\n## Detailed description of a problem\nI was trying to get the bot to the middle of a water pool, by walking it over a boat, and it wasn't moving. I eventually teleported the bot to where I wanted it and it started walking backwards.\nThe physics of the bot doesn't support walking on to boats, and instead treats them as walls.\nhttps://github.com/PrismarineJS/mineflayer/assets/55368789/cb8ab56c-939f-483a-9692-e078ec6366ce\n## Your current code\n```js\n// note: replaced chat handling with the code I used in the video\nconst mineflayer = require('mineflayer')\nconst bot = mineflayer.createBot({\n  host: 'localhost',\n  port: 44741,\n  username: 'SentryBuster',\n  auth: 'offline'\n})\nbot.once('login',()=>{\n  bot.settings.skinParts.showCape = false\n  var flipped = false;\n  setInterval(()=>{\n    let skinparts = bot.settings.skinParts;\n    // skinparts.showJacket = flipped;\n    // skinparts.showLeftSleeve = flipped;\n    // skinparts.showRightSleeve = flipped;\n    // skinparts.showLeftPants = flipped;\n    // skinparts.showRightPants = flipped;\n    skinparts.showHat = flipped;\n    flipped=!flipped\n    bot.setSettings(bot.settings);\n  },250)\n})\nbot.on('spawn',()=>{\n  bot.setControlState('forward',true);\n  setTimeout({\n    bot.setControlState('forward',false);\n    bot.setControlState('back',true);\n  },250)\n})\n```\n## Expected behavior\nI expect the bot to walk onto the boat, just like it does for slabs and stairs.\n## Additional context\nI searched the issues and found a similar issue (#228) about the bot having issues with block collision boxes, but no mention of entities.\nI definitely could modify the bot to jump, and it was only for the bot to get to the middle of the water pool, but I feel like it'd be good to make an issue about this, if anyone else encounters it, or if it becomes a bigger problem in the future. This is an edge case and may not be worth fixing."
+
+const testMd = `
+Action: request changes 
+
+# Comments
+## src/index.js
+This is a file!
+### Line
+    let aple = 1
+### Comment
+It seems like you misspelled 'apple' here. It should be 'apple'.
+## src/index.js
+### Line
+    // Export the function
+
+  
+    module.exports = {}
+### Comment
+It seems like you forgot to export a function from this module! Here's my suggested correction:
+~~~suggestion
+// Export the function
+module.exports = { myFunction }
+~~~
+`.trim().replaceAll('~~~', '```')
+const expectedMd = JSON.stringify({"lines":[{"type":"text","text":"Action: request changes "},{"type":"comment","raw":""},{"type":"text","text":""},{"type":"text","text":""},{"type":"header","level":1,"text":"Comments"},{"type":"header","level":2,"text":"src/index.js"},{"type":"text","text":"This is a file!"},{"type":"header","level":3,"text":"Line"},{"type":"text","text":""},{"type":"preformat","raw":"    let aple = 1\n","code":"let aple = 1"},{"type":"header","level":3,"text":"Comment"},{"type":"text","text":"It seems like you misspelled 'apple' here. It should be 'apple'."},{"type":"header","level":2,"text":"src/index.js"},{"type":"header","level":3,"text":"Line"},{"type":"text","text":""},{"type":"preformat","raw":"    // Export the function\n\n  \n    module.exports = {}\n","code":"// Export the function\n\n\nmodule.exports = {}"},{"type":"header","level":3,"text":"Comment"},{"type":"text","text":"It seems like you forgot to export a function from this module! Here's my suggested correction:"},{"type":"text","text":""},{"type":"code","raw":"```suggestion\n// Export the function\nmodule.exports = { myFunction }\n```","lang":"suggestion","code":"// Export the function\nmodule.exports = { myFunction }\n"},{"type":"text","text":""}],"structured":[{"type":"text","text":"Action: request changes "},{"type":"comment","raw":""},{"type":"text","text":""},{"type":"text","text":""},{"type":"section","level":1,"title":"Comments","children":[{"type":"section","level":2,"title":"src/index.js","children":[{"type":"text","text":"This is a file!"},{"type":"section","level":3,"title":"Line","children":[{"type":"text","text":""},{"type":"preformat","raw":"    let aple = 1\n","code":"let aple = 1"}]},{"type":"section","level":3,"title":"Comment","children":[{"type":"text","text":"It seems like you misspelled 'apple' here. It should be 'apple'."}]}]},{"type":"section","level":2,"title":"src/index.js","children":[{"type":"section","level":3,"title":"Line","children":[{"type":"text","text":""},{"type":"preformat","raw":"    // Export the function\n\n  \n    module.exports = {}\n","code":"// Export the function\n\n\nmodule.exports = {}"}]},{"type":"section","level":3,"title":"Comment","children":[{"type":"text","text":"It seems like you forgot to export a function from this module! Here's my suggested correction:"},{"type":"text","text":""},{"type":"code","raw":"```suggestion\n// Export the function\nmodule.exports = { myFunction }\n```","lang":"suggestion","code":"// Export the function\nmodule.exports = { myFunction }\n"},{"type":"text","text":""}]}]}]}]}) // eslint-disable-line